Data Transfer Objects
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 aCreate{Entity}Action.Update{Entity}Data— write-side: partial payload for anUpdate{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,
) {}
}
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()));
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
Datasuffix —CreateOrderDTO,ProviderDto,OrderLine. Every transport class ends inData. (anti-patterns #19) -
camelCaseproperties on DTOs —$providerId,$totalAmount. DTO properties aresnake_caseso they map straight to columns. The rest of the codebase stayscamelCase; DTOs are the carve-out. (anti-patterns #18) - Raw arrays passed between layers —
createOrder(array $data),getOrderData(int $id): array. Use a*Dataclass so the shape is typed and discoverable. (anti-patterns #9, #10) - Nested arrays instead of nested DTOs —
public readonly array $lineswithout@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. UseFilter{Entity}Data::fromArray($request->all() + [...overrides]). (anti-patterns #17)
What to read next
- Actions — the consumer of write DTOs.
- Controllers — where DTOs are built from requests.
- Eloquent and queries — the consumer of Filter DTOs and the
scopeFilterBymacro. - Anti-patterns — items #9, #10, #11, #17, #18, #19 cover the DTO mistakes the reviewer agent flags.
Contracts
The PHP interface that defines a domain's public API. Everything outside the domain talks to it through the Contract — never through a Model, Action, or Service class directly.
Controllers
How to write thin controllers that only validate a request, build a DTO, call a Contract, and shape the response — no business logic, no model access.