Skip to content

Nested Items

The #[Items] attribute lets you validate and process arrays of nested objects through a referenced Request class.

Basic Usage

Define a child Request DTO and reference it via #[Items]:

php
use Solo\RequestHandler\Attributes\{Validate, Items};
use Solo\RequestHandler\Request;

final class OrderItemRequest extends Request
{
    #[Validate('required|string|max:255')]
    public string $product;

    #[Validate('required|integer|min:1')]
    public int $quantity;

    #[Validate('required|numeric|min:0')]
    public float $price;
}

final class CreateOrderRequest extends Request
{
    #[Validate('required|string')]
    public string $customer;

    /** @var OrderItemRequest[]|null */
    #[Validate('required|array|min:1')]
    #[Items(OrderItemRequest::class)]
    public ?array $items = null;
}

Each element in the items array goes through the full processing pipeline of the referenced Request class: validation, type casting, pre/post-processing.

Input Format

json
{
    "customer": "John Doe",
    "items": [
        {"product": "Widget", "quantity": 3, "price": "9.99"},
        {"product": "Gadget", "quantity": 1, "price": "24.50"}
    ]
}

After processing:

php
$dto = $handler->handleBody(CreateOrderRequest::class, $request);

$dto->items[0]->product;  // 'Widget'
$dto->items[0]->quantity; // 3
$dto->items[0]->price;    // 9.99
$dto->items[1]->product;  // 'Gadget'

$dto->toArray();
// ['customer' => 'John Doe', 'items' => [
//     ['product' => 'Widget', 'quantity' => 3, 'price' => 9.99],
//     ['product' => 'Gadget', 'quantity' => 1, 'price' => 24.50],
// ]]

Validation Errors

Validation errors from nested items use dot-notation with the item index:

php
try {
    $dto = $handler->handleBody(CreateOrderRequest::class, $request);
} catch (ValidationException $e) {
    $e->getErrors();
    // [
    //     'items.0.product' => [['rule' => 'required']],
    //     'items.1.quantity' => [['rule' => 'min', 'params' => ['1']]],
    // ]
}

All items are validated before throwing — you get errors for every invalid item in a single exception.

Non-array elements produce a dedicated error:

php
// Input: {"items": ["not-an-object", {"product": "Widget", ...}]}

$e->getErrors();
// [
//     'items.0' => [['rule' => 'array']],
// ]

Nested Features

Child Request classes support the full attribute set:

php
use Solo\RequestHandler\Attributes\{Validate, Cast, PostProcess, Generator, Group};
use Solo\RequestHandler\Attributes\Source\FromContext;

final class OrderItemRequest extends Request
{
    #[Generator(UuidGenerator::class)]
    public string $id;

    #[Validate('required|string|max:255')]
    public string $product;

    #[Validate('required|integer|min:1')]
    public int $quantity;

    #[Validate('required|numeric|min:0')]
    #[PostProcess(RoundCents::class)]
    public float $price;

    #[FromContext('tenantId')]
    public int $tenantId;

    #[Group('pricing')]
    public ?float $discount = null;
}

Route and context in nested items

Route params are NOT propagated to nested items. They describe the outer resource (e.g. an order id), not each item. Merging them into item data would silently overwrite item-level fields whose name happened to match a route key (e.g. each item's id becoming the outer route's id).

$context IS propagated, so items can read it via #[FromContext] and item processors can access ProcessContext::$context.

php
final class LineItemRequest extends Request
{
    #[FromContext('tenantId')]
    public int $tenantId;

    #[Validate('required|integer|exists:products,id')]
    public int $productId;

    #[Validate('required|integer|min:1')]
    public int $quantity;
}

$dto = $handler->handleBody(
    OrderRequest::class,
    $request,
    route:   ['orderId' => 7],          // not visible inside items
    context: ['tenantId' => 42],        // each item picks it up via #[FromContext]
);

handleArray()

For testing or manual processing, use handleArray() to process raw data without a PSR-7 request:

php
$item = $handler->handleArray(OrderItemRequest::class, [
    'product' => 'Widget',
    'quantity' => '3',
    'price' => '9.99',
]);

$item->product;  // 'Widget'
$item->quantity; // 3 (int)
$item->price;    // 9.99 (float)

Processing Pipeline

For fields with #[Items], the processing order is:

  1. Parent field is validated (e.g., required|array|min:1)
  2. Auto-casting is skipped — the array is passed directly to items processing
  3. Each element goes through the child Request pipeline:
    • Generate values (#[Generator])
    • Pre-process (#[PreProcess])
    • Validate (#[Validate])
    • Cast types
    • Post-process (#[PostProcess])
  4. Each processed item is returned as a Request object

WARNING

Auto-casting is intentionally skipped for #[Items] fields. The BuiltInCaster won't process the array before items are handled. This prevents data corruption when the array contains structured objects.

Configuration Validation

Invalid configurations throw ConfigurationException at build time:

ErrorCauseFix
Class does not exist#[Items('NonExistent')]Check class name and imports
Must extend Request#[Items(SomeClass::class)] (not a Request)Extend Request base class
Requires array type#[Items] on string propertyUse array or ?array type
Items with generatorBoth #[Items] and #[Generator] on the same propertyRemove one — they are mutually exclusive

Practical Examples

E-commerce Order

php
final class AddressRequest extends Request
{
    #[Validate('required|string|max:255')]
    public string $street;

    #[Validate('required|string|max:100')]
    public string $city;

    #[Validate('required|string|size:2')]
    public string $country;

    #[Validate('required|string|max:20')]
    public string $zip;
}

final class OrderItemRequest extends Request
{
    #[Validate('required|integer|exists:products,id')]
    public int $productId;

    #[Validate('required|integer|min:1|max:99')]
    public int $quantity;
}

final class CreateOrderRequest extends Request
{
    #[Generator(UuidGenerator::class)]
    public string $id;

    #[Validate('required|array|min:1')]
    #[Items(OrderItemRequest::class)]
    public ?array $items = null;

    #[Validate('required|array')]
    #[Items(AddressRequest::class)]
    public ?array $addresses = null;

    #[Validate('nullable|string|max:500')]
    public ?string $notes = null;
}

Survey with Questions

php
final class AnswerRequest extends Request
{
    #[Validate('required|integer|exists:questions,id')]
    public int $questionId;

    #[Validate('required|string|max:1000')]
    public string $value;
}

final class SubmitSurveyRequest extends Request
{
    #[Validate('required|integer|exists:surveys,id')]
    public int $surveyId;

    #[Validate('required|array|min:1')]
    #[Items(AnswerRequest::class)]
    public ?array $answers = null;
}

Released under the MIT License.