Skip to content

Commit db23275

Browse files
committed
Add @var docblock-based array validation to Attributes
Attributes now automatically validates array elements when a property has a @var docblock annotation with type information. This covers generics (array<string>, array<string, Address>), list types (list<Address>), array shapes (array{name: string, age?: int}), nullable arrays (array<Address>|null), and legacy syntax (Address[]). Class names in annotations are resolved relative to the property's class namespace. Without this, array properties typed as plain `array` could only be validated via explicit #[Each] attributes on each property, requiring manual annotation that duplicates what the @var docblock already expresses. The new DocblockTypeResolver helper parses @var annotations using phpstan/phpdoc-parser, caches the resulting AST statically for performance, and resolves them to Validators on each call (so the current Attributes instance is available for cycle detection in recursive object validation). Optional shape keys (e.g., age? in array{name: string, age?: int}) use KeyOptional so the key may be absent entirely, not merely null. Union types with null (e.g., array<Address>|null) wrap the element validator in NullOr.
1 parent 8ef8f3a commit db23275

18 files changed

Lines changed: 1534 additions & 50 deletions

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"require": {
2323
"php": ">=8.5",
2424
"php-di/php-di": "^7.1",
25+
"phpstan/phpdoc-parser": "^2.0",
2526
"psr/container": "^2.0",
2627
"respect/string-formatter": "^1.7",
2728
"respect/stringifier": "^3.0",
@@ -33,7 +34,7 @@
3334
"giggsey/libphonenumber-for-php-lite": "^8.13 || ^9.0",
3435
"mikey179/vfsstream": "^1.6",
3536
"nette/php-generator": "^4.1",
36-
"pestphp/pest": "^4.6",
37+
"pestphp/pest": "^4.7",
3738
"phpbench/phpbench": "^1.4",
3839
"phpstan/extension-installer": "^1.4",
3940
"phpstan/phpstan": "^2.0",

composer.lock

Lines changed: 48 additions & 48 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/validators/Attributes.md

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,152 @@ When a property's type is a class (named, union, or intersection type), `Attribu
8888
- **Union types** (`string|NestedAddress $address`): the nested object is only validated if it passes an `Instance` check first, so string values in the union are safely skipped.
8989
- **Intersection types** (`NestedWithAttributes&Nested $address`): the nested object is validated directly, since it must satisfy all types in the intersection.
9090
- **Untyped properties** (no type declaration, or builtin types like `string`): are never recursively validated.
91-
- **Array properties**: `Attributes` **does not** recursively validate objects inside arrays. To validate each element, use the `#[Each]` attribute on the property (e.g., `#[Each(new Attributes())]`).
91+
- **Array properties with `@var` annotations**: `Attributes` automatically validates array elements when the property has a `@var` docblock annotation with type information. See [Array validation from `@var`](#array-validation-from-var) below.
92+
- **Array properties without `@var`**: When an array property has no `@var` annotation (or only `@var array` with no generic type), `Attributes` does not validate individual elements. Use the `#[Each]` attribute on the property (e.g., `#[Each(new Attributes())]`) to validate each element explicitly.
93+
94+
### Array validation from `@var`
95+
96+
When a property is typed as `array` and has a `@var` docblock annotation, `Attributes` automatically derives element validators from the annotation:
97+
98+
| `@var` annotation | Generated validator |
99+
|:---------------------------------------------|:--------------------------------------------------------------------------------------------|
100+
| `@var array<string>` | `Each(new StringType())` |
101+
| `@var array<string, int>` | `AllOf(EachKey(new StringType()), Each(new IntType()))` |
102+
| `@var array<Address>` | `Each(Given(Instance(Address), Attributes()))` |
103+
| `@var array<string, Address>` | `AllOf(EachKey(new StringType()), Each(Given(Instance(Address), Attributes())))` |
104+
| `@var list<Address>` | `Each(Given(Instance(Address), Attributes()))` |
105+
| `@var array{name: string, age: int}` | `KeySet(Key('name', StringType()), Key('age', IntType()))` |
106+
| `@var array{name: string, age?: int}` | `KeySet(Key('name', StringType()), KeyOptional('age', IntType()))` |
107+
| `@var array{name: string, address: Address}` | `KeySet(Key('name', StringType()), Key('address', Given(Instance(Address), Attributes())))` |
108+
| `@var array<string, array{name: int}>` | `AllOf(EachKey(new StringType()), Each(KeySet(Key('name', IntType()))))` |
109+
| `@var array<Address>\|null` | `NullOr(Each(Given(Instance(Address), Attributes())))` |
110+
| `@var Address[]` | `Each(Given(Instance(Address), Attributes()))` |
111+
112+
For array generics with two type parameters (`array<K, V>`), the key type `K` is validated using `EachKey` and the value type `V` is validated using `Each`, both composed via `AllOf`. For single-parameter generics (`array<V>`, `list<V>`), only values are validated since keys are implicit. Class names in annotations are resolved relative to the property's class namespace.
113+
114+
Array shape keys marked as optional (e.g., `age?` in `array{name: string, age?: int}`) use `KeyOptional` so that missing optional keys are accepted — the key itself may be absent, but if present, its value must match the declared type.
115+
116+
Here is an example of array validation from `@var` annotations:
117+
118+
```php
119+
use Respect\Validation\Validators as Validator;
120+
121+
final class Address
122+
{
123+
public function __construct(
124+
#[Validator\Not(new Validator\Undef())]
125+
public string $street,
126+
#[Validator\Not(new Validator\Undef())]
127+
public string $city,
128+
public ?string $country = null,
129+
) {
130+
}
131+
}
132+
133+
final class Company
134+
{
135+
/**
136+
* @var array<string>
137+
*/
138+
public array $tags = [];
139+
140+
/**
141+
* @var array<string, int>
142+
*/
143+
public array $scores = [];
144+
145+
/**
146+
* @var array<Address>
147+
*/
148+
public array $offices = [];
149+
150+
/**
151+
* @var array{name: string, founded?: int}
152+
*/
153+
public array $info = [];
154+
}
155+
```
156+
157+
Validating the company with correct data:
158+
159+
```php
160+
$company = new Company();
161+
$company->tags = ['tech', 'startup'];
162+
$company->scores = ['a' => 1, 'b' => 2];
163+
$company->offices = [new Address('123 Main St', 'Springfield')];
164+
$company->info = ['name' => 'Acme Corp'];
165+
v::attributes()->assert($company);
166+
// Validation passes successfully
167+
```
168+
169+
```php
170+
$company = new Company();
171+
$company->tags = ['tech', 'startup'];
172+
$company->scores = ['a' => 1, 'b' => 2];
173+
$company->offices = [new Address('123 Main St', 'Springfield')];
174+
$company->info = ['name' => 'Acme Corp', 'founded' => 1998];
175+
v::attributes()->assert($company);
176+
// Validation passes successfully
177+
```
178+
179+
Validating the company with incorrect data:
180+
181+
```php
182+
$company = new Company();
183+
$company->tags = ['tech', 123];
184+
$company->scores = ['a' => 1];
185+
$company->info = ['name' => 'Acme'];
186+
v::attributes()->assert($company);
187+
// → `.tags.1` must be a string
188+
```
189+
190+
```php
191+
$company = new Company();
192+
$company->tags = ['tech'];
193+
$company->scores = ['a' => 1, 'b' => 'not an int'];
194+
$company->info = ['name' => 'Acme'];
195+
v::attributes()->assert($company);
196+
// → `.scores.b` must be an integer
197+
```
198+
199+
```php
200+
$company = new Company();
201+
$company->tags = ['tech'];
202+
$company->scores = [0 => 1, 1 => 2];
203+
$company->info = ['name' => 'Acme'];
204+
v::attributes()->assert($company);
205+
// → - Each key in `.scores` must be valid
206+
// → - Key `.scores.0` must be a string
207+
// → - Key `.scores.1` must be a string
208+
```
209+
210+
```php
211+
$company = new Company();
212+
$company->tags = ['tech'];
213+
$company->scores = ['a' => 1];
214+
$company->offices = [new Address('', 'not a city')];
215+
$company->info = ['name' => 'Acme'];
216+
v::attributes()->assert($company);
217+
// → `.offices.0.street` must be defined
218+
```
219+
220+
```php
221+
$company = new Company();
222+
$company->tags = ['tech'];
223+
$company->scores = ['a' => 1];
224+
$company->info = ['name' => 42];
225+
v::attributes()->assert($company);
226+
// → `.info.name` must be a string
227+
```
228+
229+
```php
230+
$company = new Company();
231+
$company->tags = ['tech'];
232+
$company->scores = ['a' => 1];
233+
$company->info = ['name' => 'Acme', 'founded' => 'not a year'];
234+
v::attributes()->assert($company);
235+
// → `.info.founded` must be an integer
236+
```
92237

93238
### Circular references
94239

0 commit comments

Comments
 (0)