PHP Data Transfer Objects Explained
You know that moment when you're staring at a function that returns an array, and you genuinely don't know what keys it contains? Or when you pass data between layers of your application and somewhere down the line, something gets modified that shouldn't have been? Yeah. That's the moment a lot of PHP developers discover they need something better. That something is the Data Transfer Object—or DTO.
I want to talk about DTOs not as some abstract pattern from a textbook, but as a real solution to problems you've probably already felt in your code. The kind of problems that accumulate quietly, then suddenly explode into a debugging nightmare at 2 AM.
What a DTO actually is
Let's start with the honest answer: a DTO is just a class designed to move data from one place to another. Nothing magical. No hidden tricks. It's a structured, typed container for your information.
Think about it this way. When you fetch user data from your database, you get an array—or maybe a model object—loaded with properties. When you need to pass that data to your service layer, or send it through an API, or store it in a cache, you're making a decision about what that data looks like. A DTO is your way of saying: "This is what this data looks like when it travels between these two points."
The pattern comes from Java and distributed systems where you needed a clear contract about what data was being transferred across process boundaries. PHP is different—we're usually working within a single application. But the principle still holds. A DTO gives you a contract. It gives you clarity. It gives you confidence.
Here's a simple example. Imagine you're building a patient management system:
class StorePatientDto
{
public function __construct(
public readonly string $firstName,
public readonly string $lastName,
public readonly string $address,
public readonly ?string $healthCardId,
) {}
}
That's it. That's a DTO. A container with some properties and nothing else. Notice those readonly keywords? That's immutability—a core characteristic of DTOs. Once you create this object, the data inside doesn't change. That matters more than you might think.
Why you actually need this
I get it. Your first instinct might be: "Why not just use an array? Or a plain object? Why add another class to my codebase?"
Fair question. Let me show you why it matters.
Let's say you're handling a request to create a patient. Without a DTO, your controller might look like this:
class StorePatientController
{
public function __invoke(StorePatientRequest $request)
{
$validated = $request->validated();
$data = [
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'address' => $validated['address'],
'health_card_id' => $validated['health_card_id'],
];
$result = new StorePatientService($data);
return response()->json(['message' => 'Patient created'], 201);
}
}
That array is loose. It's mutable. Somewhere in your service layer or deeper in your code, someone could modify it. Someone could add a key that shouldn't exist. Someone could forget what keys are supposed to be there. You're passing around a bag of data with no guardrails.
Now with a DTO:
class StorePatientController
{
public function __invoke(StorePatientRequest $request)
{
$validated = $request->validated();
$data = new StorePatientDto(
firstName: $validated['first_name'],
lastName: $validated['last_name'],
address: $validated['address'],
healthCardId: $validated['health_card_id'],
);
$result = new StorePatientService($data);
return response()->json(['message' => 'Patient created'], 201);
}
}
See the difference? You're not passing a nebulous array anymore. You're passing a concrete, typed, immutable object. Your service layer knows exactly what it's receiving. Your IDE knows what properties are available. Your future self—the one debugging something three months from now—will thank you.
Here's what DTOs give you:
- Type safety. You know what properties exist. Your editor will help you. You won't have typos that silently fail.
- Immutability. The data can't be accidentally mutated after creation. This prevents whole categories of bugs.
- Documentation. The DTO class is self-documenting. Anyone reading your code sees immediately what data flows through your system.
- Testability. When you're writing unit tests for your service layer, you can create DTOs easily, knowing exactly what you're testing against.
- Clear contracts between layers. Your controller talks to your service layer. Your service layer doesn't care about HTTP requests or Laravel Request objects. It cares about structured data. The DTO is that contract.
How DTOs fit into your architecture
Let's be honest: not every value in your application needs a DTO. If you're writing a small utility function that returns two values, maybe an array is fine. But when you're thinking about data flowing across architectural boundaries—from your controller to your service layer, from your API endpoint to your business logic—that's where DTOs shine.
Consider a typical flow in a Laravel application:
- A user submits a form or hits an API endpoint.
- Laravel's request validation runs, checking that the data is valid.
- Your controller takes that validated data and transforms it into a DTO.
- Your service layer or business logic receives the DTO and operates on it.
- Changes flow back out to the database or the response.
At step 3, you're creating a boundary. You're saying: "Everything beyond this point doesn't need to know about HTTP requests, form submissions, or Laravel's Request class. Everything beyond this point just knows about data."
That separation is powerful. It makes your code easier to test, easier to reason about, and easier to change without breaking things unexpectedly.
Building DTOs the right way
There are a few conventions that have emerged in the PHP community, and they're worth following. Not because someone made a rule, but because these patterns solve real problems.
Keep them simple. A DTO should contain data and nothing else. No business logic. No database queries. No API calls. If you find yourself writing methods that do something other than expose data, you've probably put too much into your DTO. Your DTO is not a model. It's not an entity. It's a container. Remember that distinction.
Use immutability. With PHP 8.1 and later, you have access to the readonly keyword. Use it. This isn't about being pedantic. It's about preventing bugs. When you know data can't change, you can reason about it more clearly. You can pass it around without worrying that something downstream will modify it unexpectedly.
Name them clearly. If you're transferring user data from a form submission to your business logic, call it StoreUserDto or CreateUserDto. Not UserTransfer or UserData. The name should tell you exactly what purpose this DTO serves in your application.
Don't try to use one DTO for everything. You might be tempted to create a single UserDto that handles reading, creating, updating, and deleting users. Resist that. Different operations need different data shapes. When you're creating a user, you probably don't need an ID. When you're updating one, you do. When you're returning user data in an API response, you might exclude the password. Create separate DTOs for separate purposes. It seems like more code at first, but it's clearer and more maintainable.
Consider transformation methods. Sometimes you need to convert an array into a DTO, or vice versa. You could put that logic in the DTO itself, creating a factory method:
class StorePatientDto
{
public readonly string $firstName;
public readonly string $lastName;
public readonly string $address;
public readonly ?string $healthCardId;
public function __construct(array $patientData)
{
$this->firstName = $patientData['first_name'] ?? '';
$this->lastName = $patientData['last_name'] ?? '';
$this->address = $patientData['address'] ?? '';
$this->healthCardId = $patientData['health_card_id'] ?? null;
}
}
This makes your controller cleaner: instead of doing manual mapping, you just pass the array to the DTO constructor.
Where DTOs fit in real applications
Let me give you some practical scenarios where DTOs earn their place in your code.
API responses. You're building a REST API. Your database model has sensitive fields like password hashes, API tokens, and internal IDs. You don't want to accidentally expose those. A response DTO lets you define exactly which fields go out in your API:
class UserResponseDto
{
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly string $email,
) {}
}
You create instances of this DTO from your User model, and your API layer returns that DTO. The controller transforms it to JSON. The sensitive data stays hidden.
Service layer input. Your business logic needs structured data. A DTO makes that explicit. Anyone using your service knows what they need to pass in. No guessing. No "what keys does this service expect again?" moments.
Cache serialization. You want to store data in Redis or Memcache. DTOs are simple enough to serialize and deserialize without headaches. An array might work, but a DTO makes the intent clearer: "This is a complete unit of data that I'm storing."
Cross-process communication. You're queuing jobs, or calling another microservice, or storing data in a message queue. DTOs give you a clear structure that you can reliably serialize to JSON and back.
Mapping between domains. You have a database layer and a domain model layer and an API layer. Each one might represent data slightly differently. DTOs are the translators. Your controller receives a DTO, transforms it into a domain model, saves to the database. Then the database returns data, which gets transformed into a response DTO. Clear boundaries everywhere.
The common mistakes people make
I've seen DTOs misused in ways that defeat their purpose. Let me call out the biggest ones.
Treating them as models. DTOs and models serve different purposes. A model represents something in your domain and contains business logic. A DTO is just a container. If you find yourself putting methods into your DTO that do calculations, fetch related data, or modify the object's state, you've crossed the line. Move that logic to a service or a model where it belongs.
Making one DTO do everything. You have a User in your database. You use one UserDto for reading users, creating them, updating them, responding to API requests, and caching them. Don't do this. Different scenarios need different shapes. Create CreateUserDto, UpdateUserDto, UserResponseDto. More classes, yes. But each one has a clear, single purpose.
Adding validation logic to DTOs. Validation happens before the DTO is created. Your request validation runs. Then you create the DTO with validated data. The DTO itself doesn't validate. If it did, you'd be mixing concerns. Keep DTOs pure.
Overthinking it for small projects. If you're writing a simple script that doesn't have layers or clear architectural boundaries, DTOs might be overkill. They're a tool that shines in larger, more complex applications. Use judgment. Introduce them when your codebase starts to have clear separation between layers.
The future is already here
PHP 8.1 made DTOs significantly more elegant with the readonly keyword and constructor property promotion. If you're still on PHP 8.0 or earlier, you can absolutely use DTOs—they just require a bit more boilerplate. But if you have the option to upgrade, do it. The language is moving toward making immutable data structures first-class citizens, and DTOs are going to become even more central to how we write PHP.
Libraries are emerging that help with DTO creation and mapping. But honestly, you don't need a library for something so simple. A few well-structured classes in your application will serve you better than a heavyweight dependency. Understand the pattern. Implement it yourself. Own it.
The quiet confidence DTOs bring
Here's what I've noticed over years of writing PHP. When your code has clear boundaries, when data flows through your application in predictable, typed channels, when you can look at a method signature and understand exactly what data it's working with—that changes how you work.
You stop worrying about typos in array keys. You stop wondering if some hidden part of your code is mutating data. You move faster because your IDE helps you. You debug faster because the structure is explicit. You sleep better because you know your code is more resilient to change.
That's what DTOs do. They're not flashy. They don't solve any algorithm problem. They don't make your application faster. But they make your code clearer, safer, and more maintainable. And in a field where clarity is rarer than genius, that's actually everything.
Start small. Create a DTO for your next feature. Feel how it changes the way you think about your code. Then expand from there. You'll find that once you start using them, you'll wonder how you ever lived without them.