Lesson 3 of 3 · Small Objects, Big Impact
Data Transfer Objects
The array habit
PHP developers are used to working with arrays, whether they're at the start of their programming career or years into it. Arrays seem very simple to use. They're built into the language. They take any keys, any values, any depth. There's nothing to design and no class to write.
Arrays are fine for lists of homogeneous things, maps with dynamic keys, and intermediate results inside a function. They fall short for a set of named, typed fields, and they fall short when things get complex. It may look like they're fine, but they lack types and visibility.
There's no way to know what the data inside an array looks like, or what each of the keys contains, or what type the values are. The array doesn't tell you. The function that produced the array doesn't tell you. The function that consumes it has to either trust, validate, or guess.
Let's take a quick example of a very common controller you may see in a Laravel application:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Reservations\ReservationService;
use App\Http\Requests\RequestReservationRequest;
use Illuminate\Http\JsonResponse;
final class RequestReservationController
{
public function __construct(
private ReservationService $service,
) {}
public function __invoke(RequestReservationRequest $request): JsonResponse
{
$this->service->request($request->validated());
return new JsonResponse(['status' => 'ok']);
}
}As you can see in the example (ReservationService is not shown), the service class takes a list of validated fields. The fields come back from $request->validated() as an array. The service method has to know what's in that array. The controller has to trust that the array is shaped the way the service expects.
Looking at code like this, the next step is to open the request class and see all the fields, to learn what they are and what types they have.
Here's an example of the form request:
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
final class RequestReservationRequest extends FormRequest
{
public function rules(): array
{
return [
'guest_id' => ['required', 'string'],
'room_id' => ['required', 'string'],
'check_in' => ['required', 'date'],
'check_out' => ['required', 'date', 'after:check_in'],
'adults' => ['required', 'integer', 'min:1'],
'children' => ['nullable', 'integer', 'min:0'],
'rate_plan_code' => ['required', 'string'],
'special_requests' => ['nullable', 'string'],
];
}
}This somewhat helps with the main visibility problem, because you can see the validation rules. But what if not all validation rules are applied? What if adults doesn't have a required rule and is sometimes missing? What if children is nullable and the service has to handle the null case?
In order to see all the values and their types, you'd have to debug the array returned by $request->validated(), or visit the page, or experiment with the API endpoint. None of those are fast, and all of them have to be repeated by every new developer who ever touches this code.
This is one of the main reasons why data transfer objects are a good option to use instead of arrays.
What a DTO actually is
Data transfer objects (DTOs) are objects (or classes) that carry data. They don't have methods that represent behavior, validate data, return responses, or execute specific actions. They're a typed bag of values, plus enough construction logic to fill that bag.
That's the whole definition. A DTO that does anything else has crossed into being a different kind of object, and the difference matters. A DTO that validates its own data is turning into a value object. A DTO with behavior in it is a domain object. A DTO that returns responses is a controller.
Here's the DTO version of the example above:
<?php
declare(strict_types=1);
namespace App\Reservations;
use Carbon\CarbonImmutable;
final readonly class RequestReservationData
{
public function __construct(
public string $guestId,
public string $roomId,
public CarbonImmutable $checkIn,
public CarbonImmutable $checkOut,
public int $adults,
public int $children,
public string $ratePlanCode,
public ?string $specialRequests,
) {}
public static function fromArray(array $data): self
{
return new self(
guestId: $data['guest_id'],
roomId: $data['room_id'],
checkIn: CarbonImmutable::parse($data['check_in']),
checkOut: CarbonImmutable::parse($data['check_out']),
adults: (int) $data['adults'],
children: (int) ($data['children'] ?? 0),
ratePlanCode: $data['rate_plan_code'],
specialRequests: $data['special_requests'] ?? null,
);
}
}The class contains the properties and their types, and one or more methods that the class can be instantiated from. Here are a few examples of factory methods you may see on a DTO:
fromArrayfromJsonfromXmlfromRequest(anIlluminate\Http\Request)fromModel(an Eloquent model)and many more
The purpose of these methods is to map the data into the typed properties. Once the DTO is built, the rest of the codebase doesn't deal with arrays, JSON strings, or framework request objects. It deals with one typed object that already knows what it has.
Here's how the controller looks once the DTO is in place:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\RequestReservationRequest;
use App\Reservations\RequestReservationData;
use App\Reservations\ReservationService;
use Illuminate\Http\JsonResponse;
final class RequestReservationController
{
public function __construct(
private ReservationService $service,
) {}
public function __invoke(RequestReservationRequest $request): JsonResponse
{
$data = RequestReservationData::fromArray($request->validated());
$this->service->request($data);
return new JsonResponse(['status' => 'ok']);
}
}The service signature changes from request(array $fields) to request(RequestReservationData $data). The reader, the IDE and the type system all know what fields exist before the body is opened.
What DTOs buy you
The main benefits DTOs bring:
Structure. The data has a shape. The shape is enforced by the type system.
Visibility. A reader can see what data is being transferred and what type it has, in one file, without running the code.
Better tooling. Intellisense, refactor-rename, "go to definition", and static analysis all work properly. None of those work on
$request->validated()['check_in'].Errors caught before production. Misspell a key in an array and you find out in production. Misspell a property on a DTO and the IDE or PHPStan flags it before the code runs.
A natural place to put the construction logic. Parsing the date, normalizing the country code, defaulting the boolean. All of it lives in
fromArray. The rest of the system never repeats it.
The cost is one extra file per DTO.
Where DTOs show up
The main use of a DTO is transferring data from a controller to a service or use case, but they also show up in:
An event being dispatched and consumed by a listener.
A command being passed to a command handler.
Data coming from a different delivery mechanism: a console command, a queue worker, a webhook, a scheduled job.
Data leaving the system: a structured response to an API client, a payload sent to a third party.
The first three are what I'd call input DTOs: data flowing into a use case. You can also use DTOs for output DTOs: data shaped for a specific consumer, especially when that consumer is outside your system.
The same shape (small class, typed properties, factory methods, no behavior) covers both directions. Keep it to one DTO per use case, sitting at the boundary. A chain like RequestDto -> ServiceDto -> CommandDto -> HandlerDto, all carrying the same fields, is the over-engineering trap from Foundations Lesson 7.
"Aren't DTOs just extra files?"
It may look like DTOs are unnecessary at first, because they require you to create additional files. They do help in more complex projects that need to be maintained for years, especially for new people on the team trying to figure out what the code does and what data it works with.
Each new file feels like overhead, and the examples here are deliberately simple, which makes the cost-benefit ratio look worse than it is in practice.
DTOs shine in situations where you have many values to work with in a single request, API endpoint, or service call. That's where the array reading becomes painful and the typed object becomes a real win.
A note on packages
You don't need to use a package for data transfer objects.
Some people prefer packages that do validation, set headers, return responses, and reduce the code that needs to be written. Those packages are useful tools, but those aren't really data transfer objects by the original definition. They're a different thing wearing the DTO label.
A plain PHP class with final readonly, typed promoted properties, and a couple of static factory methods is enough. PHP 8.2+ gives you everything you need built in. Adding a dependency to do what a few lines of native PHP already do is rarely worth it.
If you do reach for a package, pick one that gives you exactly the DTO behavior and nothing else. The packages that bundle DTO-like classes with validation, request handling, response shaping, and casting are doing too much.
A practical sequence
If you want to introduce DTOs to a real codebase, the cheapest way to start is small.
Pick one endpoint or service method where the array is actually causing pain. Many fields, optional values, type ambiguity.
Add a DTO with
fromArray(orfromRequest) and the typed properties. The guest and room IDs can stay strings in the DTO; the service wraps them inGuestIdandRoomId.Change one consumer to take the DTO instead of the array.
Watch what happens in code review. If the team finds it useful, the next endpoint that hurts gets the same treatment.
The one thing to remember
An array is a bag of values. A DTO is the same bag with the lid labeled, and the code downstream reads much better for it.
When you find yourself opening a form request to figure out what's in $request->validated(), that array wants a DTO.
Related Links
- Data Transfer ObjectFowler's original description of the pattern, from Patterns of Enterprise Application Architecture.
- Is it a DTO or a Value Object?Matthias Noback draws the same line this lesson draws, between a typed bag of data and an object that enforces its own rules.
- Introduce Parameter ObjectThe refactoring for replacing a group of parameters that travel together with one object, the same shape a DTO takes at a service boundary.