Key Concepts

Data Transfer Objects

How to define typed payloads (DTOs) that flow between requests, Contracts, Actions, and Eloquent without a mapping layer.

A Data Transfer Object (DTO) is a small typed class that carries data between layers — from a request into an Action, from an Action back to a controller, between Services. Every DTO in this codebase lives in src/Domain/{Domain}/DataTransferObjects/, ends its class name with Data, and uses public readonly properties named to match the database column they represent.

You use DTOs so callers don't have to guess what's in a payload. When an Action accepts CreateOrderData, the reader knows exactly which fields are required, which are optional, and what their types are. When it accepts array $data, the reader knows nothing — every caller has to guess the keys, and every renamed field becomes a silent runtime break.

DTOs are also how the project avoids a mapping layer between the request, the Action, and the database. By matching property names to DB column names, Filter{Entity}Data::fromArray($request->all()) and Model::create($data->toArray()) work without a translation step.

The shape

namespace Domain\Order\DataTransferObjects;

use Domain\Core\Traits\Arrayable;

final class CreateOrderData
{
    use Arrayable;

    public function __construct(
        public readonly int $provider_id,
        public readonly int $customer_id,
        public readonly float $total_amount,
        public readonly ?string $notes = null,
    ) {}
}

// Usage in a controller
$data = CreateOrderData::fromArray($request->validated());
Order::create($data->toArray());

Three things to notice: the class name ends in Data, every property is public readonly and snake_case, and the Arrayable trait gives you fromArray() and toArray() for free.

Naming — always end with Data

Every DTO class ends with Data. The suffix is how the rest of the codebase recognises an inter-layer transport object.

// ✅ Good
class CreateOrderData { }
class UpdateOrderData { }
class FilterOrderData { }
class OrderLineData { }
// ❌ Bad — wrong suffix
class CreateOrderDTO { }
class OrderLine { }
class FilterProviderDto { }

Three shapes cover almost every case:

  • Filter{Entity}Data extends FilterData — read-side: query parameters wrapped in a typed object.
  • Create{Entity}Data — write-side: payload for a Create{Entity} Action.
  • Update{Entity}Data — write-side: partial payload for an Update{Entity} Action.

Properties use snake_case to match columns

Every public readonly property on a *Data class is snake_case so it lines up 1:1 with the underlying database column. This is a project-wide carve-out — the rest of the codebase stays camelCase.

Why: Model::create($data->toArray()), Model::update(Arr::whereNotNull($data->toArray())), and Filter{Entity}Data::fromArray($request->all()) all rely on key names matching DB column names. A mapping layer would only exist to translate between providerId and provider_id, so the DTO skips camelCase entirely and eliminates the layer.

// ❌ Bad — camelCase forces a mapping layer
final class CreateOrderData
{
    public function __construct(
        public readonly int $providerId,
        public readonly int $customerId,
        public readonly float $totalAmount,
        public readonly ?int $cityId = null,
    ) {}
}
// ✅ Good — snake_case maps straight to columns
final class CreateOrderData
{
    public function __construct(
        public readonly int $provider_id,
        public readonly int $customer_id,
        public readonly float $total_amount,
        public readonly ?int $city_id = null,
    ) {}
}
This is the only place snake_case is correct in PHP code. Plain variables, parameters, and method names stay camelCase everywhere else.

Use the Arrayable trait

Every DTO uses Domain\Core\Traits\Arrayable. It supplies a toArray() that produces a snake_case payload ready for Eloquent and a fromArray() factory that hydrates the DTO from an associative array. Because property names match column names, no key remapping is needed.

namespace Domain\Order\DataTransferObjects;

use Domain\Core\Traits\Arrayable;

final class CreateOrderData
{
    use Arrayable;

    public function __construct(
        public readonly int $provider_id,
        public readonly int $customer_id,
        public readonly float $total_amount,
    ) {}
}

$data = CreateOrderData::fromArray($request->validated());
Order::create($data->toArray());

Filter DTOs — every property nullable

Filter DTOs extend Domain\Core\DataTransferObjects\FilterData. Every property is nullable with a null default so an empty filter is valid. The pattern pairs with a scopeFilterBy trait on the model (see Eloquent and queries).

namespace Domain\Order\DataTransferObjects;

use Domain\Core\DataTransferObjects\FilterData;
use Domain\Core\Traits\Arrayable;

final class FilterOrderData extends FilterData
{
    use Arrayable;

    public function __construct(
        public readonly ?int $id = null,
        public readonly ?string $name = null,
        public readonly ?string $status = null,
    ) {}
}

For filters that accept a single value, an array, or null, use union types. They pair with the whereByType macro in the scope.

final class FilterProviderData extends FilterData
{
    use Arrayable;

    /** @param string|array<ProviderStatus>|null $status */
    public function __construct(
        public readonly string|array|null $id = null,
        public readonly string|ProviderStatus|array|null $status = null,
        public readonly int|array|null $city_ids = null,
    ) {}
}

Create DTOs — required fields up front

Create{Entity}Data carries the full payload an Action needs to insert a row. Required properties have no default; optional columns are nullable with null defaults.

namespace Domain\Order\DataTransferObjects;

use Domain\Core\Traits\Arrayable;

final class CreateOrderData
{
    use Arrayable;

    public function __construct(
        public readonly int $provider_id,
        public readonly int $customer_id,
        public readonly float $total_amount,
        public readonly ?string $notes = null,
    ) {}
}

Update DTOs — every property nullable

Update{Entity}Data is different from Create{Entity}Data: every property is nullable. Partial updates are the rule, and the Update{Entity} Action strips nulls with Arr::whereNotNull($data->toArray()) so unset properties never blank existing columns.

final class UpdateOrderData
{
    use Arrayable;

    public function __construct(
        public readonly ?int $provider_id = null,
        public readonly ?int $customer_id = null,
        public readonly ?float $total_amount = null,
        public readonly ?string $notes = null,
    ) {}
}
// Inside the Action — null-safe partial update
$order->update(Arr::whereNotNull($data->toArray()));
If you forget to make every Update DTO property nullable, a partial update will pass null for unset fields and blank existing columns. This is a common cause of "the customer's name disappeared after I edited their phone number" bugs.

How a controller builds a DTO

There are two paths, depending on the side of the request.

Filter DTOs — fromArray($request->all())

Filters are read-only and the Filter DTO only declares safe, nullable properties. Passing $request->all() is fine because any extra keys are ignored by the typed constructor, and matching keys hydrate by name. Merge any computed values to the right of + so they override request input where the controller intentionally pins a value.

public function index(Request $request): View
{
    $filters = FilterEmployeeData::fromArray($request->all() + [
        'winch_branch_id' => $request->user()->winch_branch_id,
        'is_active' => true,
        'pages_count' => 20,
    ]);

    return view('cp::employees.index', [
        'employees' => $this->employeeContract->getEmployees($filters)->pagination(),
    ]);
}

Create and Update DTOs — $request->validated() from a FormRequest

Writes must never accept raw $request->all(). A FormRequest enforces the schema first; then fromArray($request->validated()) hydrates the DTO. The validation rules and the DTO property list should mirror each other one-for-one.

final class StoreOrderRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'provider_id' => ['required', 'integer', 'exists:providers,id'],
            'customer_id' => ['required', 'integer', 'exists:users,id'],
            'total_amount' => ['required', 'numeric', 'min:0'],
            'notes' => ['nullable', 'string', 'max:1000'],
        ];
    }
}
public function store(StoreOrderRequest $request): ResponseBuilder
{
    $data = CreateOrderData::fromArray($request->validated());
    $order = app(OrderContract::class)->createOrder($data);

    return res(status: 201)->data(OrderResource::make($order));
}

Custom fromRequest() factories

When the request needs shaping that fromArray() can't express — nested DTO arrays, computed fields, type coercion — add a static fromRequest() factory on the DTO. Keep the controller thin; the mapping logic stays inside the DTO.

final class CreateOrderData
{
    use Arrayable;

    /** @param OrderLineData[] $lines */
    public function __construct(
        public readonly int $provider_id,
        public readonly int $customer_id,
        public readonly float $total_amount,
        public readonly array $lines,
        public readonly ?string $notes = null,
    ) {}

    public static function fromRequest(StoreOrderRequest $request): self
    {
        $lines = array_map(
            fn (array $line) => new OrderLineData(
                product_id: $line['product_id'],
                quantity: $line['quantity'],
                price: $line['price'],
            ),
            $request->validated('lines'),
        );

        return new self(
            provider_id: $request->validated('provider_id'),
            customer_id: $request->validated('customer_id'),
            total_amount: $request->validated('total_amount'),
            lines: $lines,
            notes: $request->validated('notes'),
        );
    }
}

Nested DTOs — never a raw array

When a DTO needs structured sub-data — order lines, an address block, a list of attachments — use another DTO class, never a raw associative array. Add PHPDoc on the array property so static analysis and the IDE see the element type.

final class OrderLineData
{
    use Arrayable;

    public function __construct(
        public readonly int $product_id,
        public readonly int $quantity,
        public readonly float $price,
    ) {}
}

final class CreateOrderData
{
    use Arrayable;

    /** @param OrderLineData[] $lines */
    public function __construct(
        public readonly int $provider_id,
        public readonly int $customer_id,
        public readonly array $lines,
        public readonly ?string $notes = null,
    ) {}
}
// ❌ Bad — raw nested array, no element type, no validation surface
final class CreateOrderData
{
    public function __construct(
        public readonly int $provider_id,
        public readonly array $lines,
    ) {}
}

A raw nested array leaves callers guessing, makes IDE autocomplete on $data->lines[0]->... impossible, and produces silent bugs the next time someone renames a key.

Anti-patterns to avoid

  • Class without the Data suffixCreateOrderDTO, ProviderDto, OrderLine. Every transport class ends in Data. (anti-patterns #19)
  • camelCase properties on DTOs$providerId, $totalAmount. DTO properties are snake_case so they map straight to columns. The rest of the codebase stays camelCase; DTOs are the carve-out. (anti-patterns #18)
  • Raw arrays passed between layerscreateOrder(array $data), getOrderData(int $id): array. Use a *Data class so the shape is typed and discoverable. (anti-patterns #9, #10)
  • Nested arrays instead of nested DTOspublic readonly array $lines without @param OrderLineData[]. Inner structures get their own DTO with PHPDoc on the parent array property. (anti-patterns #11)
  • Manually constructing a FilterData with new — long property-by-property mapping. Use Filter{Entity}Data::fromArray($request->all() + [...overrides]). (anti-patterns #17)
  • Actions — the consumer of write DTOs.
  • Controllers — where DTOs are built from requests.
  • Eloquent and queries — the consumer of Filter DTOs and the scopeFilterBy macro.
  • Anti-patterns — items #9, #10, #11, #17, #18, #19 cover the DTO mistakes the reviewer agent flags.