Test your knowledge
Take a quiz on this chapter to see how well you understood the concepts.
Take QuizData lives at the core of almost every project. You do not start by building controllers and jobs — you start by figuring out what data the application will handle. ERDs, diagrams, database schemas: these come first because everything else depends on them.
This chapter is about making data a first-class citizen of your codebase. We are not talking about Eloquent models yet. We are talking about plain data: the input that flows into your application and the structures that carry it through your business logic.
The problem with arrays
Have you ever worked with an "array of stuff" that was actually more than just a list? You used array keys as fields, and you felt the pain of not knowing exactly what was in that array.
Here is a common Laravel pattern:
function store(StudentRequest $request, Student $student)
{
$validated = $request->validated();
$student->full_name = $validated['full_name'];
$student->email = $validated['email'];
// ...
}
What exactly is in $validated? You do not know without:
- Reading the source code of
StudentRequest - Reading the documentation
- Dumping
$validatedto inspect it - Using a debugger
Now imagine a colleague wrote this code five months ago. You will not know what data you are working with without doing one of those cumbersome things.
Arrays are versatile, but as soon as they represent something other than "a list of things", there are better ways.
PHP's type system
To understand why Data Transfer Objects matter, you need to understand PHP's type system.
PHP is weakly typed. A variable can change its type after being defined:
$price = '19.99'; // string
function applyDiscount(float $price): float
{
// PHP automatically casts '19.99' to float
return $price * 0.9;
}
applyDiscount($price); // Works, even though $price is a string
This makes sense for a language that mainly works with HTTP requests, where everything starts as a string. But it means the type system cannot give you strong guarantees about your data.
PHP is also dynamically typed. Type checks happen at runtime, not before the code executes. If there is a type error, you only discover it when that code path runs.
Tools like PHPStan, Psalm, and your IDE's built-in analysis help bridge this gap by performing static analysis on your code. They can catch many type errors without ever running the program. But they can only help if you give them something to work with — and that means using typed structures instead of generic arrays.
Data Transfer Objects
The solution is to wrap unstructured data into typed classes called Data Transfer Objects (DTOs):
class StudentData
{
public function __construct(
public readonly string $full_name,
public readonly string $email,
public readonly Carbon $enrolled_at,
) {}
}
Now your IDE always knows what data you are dealing with:
function store(StudentRequest $request, Student $student)
{
$data = new StudentData(...$request->validated());
$student->full_name = $data->full_name;
$student->email = $data->email;
$student->enrolled_at = $data->enrolled_at;
}
Type $data-> and your IDE shows you exactly three properties with their types. No guessing, no debugging, no reading source code.
The benefits compound over time:
- Autocompletion works everywhere the DTO is used
- Refactoring is safe — rename a property and your IDE updates all usages
- Static analysis catches type mismatches before runtime
- New team members can understand the data flow immediately
DTO factories
There are two ways to construct DTOs from external data like HTTP requests.
Approach 1: Dedicated factory class. This is the theoretically correct approach because it keeps application-specific logic (the request) out of the domain (the DTO):
class StudentDataFactory
{
public function fromRequest(StudentRequest $request): StudentData
{
return new StudentData(
full_name: $request->get('full_name'),
email: $request->get('email'),
enrolled_at: Carbon::make($request->get('enrolled_at')),
);
}
}
This factory lives in the application layer because it knows about StudentRequest.
Approach 2: Static constructor on the DTO itself. This is more pragmatic but mixes application logic into the domain:
class StudentData
{
public function __construct(
public readonly string $full_name,
public readonly string $email,
public readonly Carbon $enrolled_at,
) {}
public static function fromRequest(StudentRequest $request): self
{
return new self(
full_name: $request->get('full_name'),
email: $request->get('email'),
enrolled_at: Carbon::make($request->get('enrolled_at')),
);
}
}
The second approach is less pure but more convenient. DTOs are the entry point for data into your codebase. Since you need to do the mapping somewhere, doing it on the class itself keeps the "how to create this" knowledge co-located with the class definition.
With PHP 8's named arguments, both approaches are clean and readable. Choose whichever fits your team best.
When DTOs shine
DTOs become especially valuable when data passes through multiple layers. Consider a course publishing flow:
- A controller receives a request
- The request data is mapped to a
CourseDataDTO - The DTO is passed to
PublishCourseActionaction - The action uses the DTO to create the course and its lessons
At every step, every developer knows exactly what data is available. The DTO acts as a contract between layers.
class CourseData
{
public function __construct(
public readonly string $instructor_id,
public readonly Carbon $starts_at,
/** @var LessonData[] */
public readonly array $lessons,
) {}
}
class LessonData
{
public function __construct(
public readonly string $title,
public readonly int $video_count,
public readonly int $duration_minutes,
public readonly bool $has_quiz,
) {}
}
Without DTOs, you would be passing arrays around. Every function that receives the array would need to hope that the right keys exist with the right types. With DTOs, the type system enforces this for you.
The cost of DTOs
DTOs do have overhead: you need to create the class and map data into it. For a simple form with three fields, this might feel excessive.
But the cost is front-loaded. You spend a few minutes creating the DTO once. You save time every single day that a developer works with that data without needing to debug what is in it.
In projects that last years with multiple developers, the investment pays for itself many times over.
Scaling up with spatie/laravel-data
The plain DTOs shown in this chapter are all you need to get started. But as your project grows, spatie/laravel-data can remove much of the boilerplate. It handles transformation, serialization, nested casting, and automatic construction from requests — while keeping the same typed, readonly philosophy.
For a deeper comparison of plain DTOs versus spatie/laravel-data, including the toDto() bridge pattern between form requests and DTOs, see the Data Transfer Objects chapter in Clean Code in Laravel.
The goal is to reduce cognitive load. You do not want developers starting their debugger every time they need to know what is in a variable. The information should be right there, at their fingertips.
Summary
- Arrays are versatile for lists, but as soon as they represent structured data (like a validated form), they lose type information. You cannot autocomplete, refactor safely, or know what keys exist without reading source code.
- Data Transfer Objects (DTOs) wrap unstructured data into typed classes with readonly properties. Your IDE, static analysis tools, and future developers all benefit from explicit types.
- DTOs can be constructed via a dedicated factory class (purer separation of domain and application) or a static constructor on the DTO itself (more pragmatic and co-located). Choose whichever fits your team.
- DTOs shine when data passes through multiple layers — controller to action to model. At every step, every developer knows exactly what data is available.
- The cost of DTOs is front-loaded: a few minutes to create the class. The payoff compounds over time as developers work with typed, autocomplete-friendly data instead of guessing array keys.
References
- Data Transfer Objects in PHP — Brent Roose
- PHP 8: Named Arguments — Brent Roose
- PHP 8.1: Readonly Properties — Brent Roose
- Constructor Promotion in PHP 8 — PHP Manual
- spatie/laravel-data — Spatie Documentation
- Data Transfer Objects — Clean Code in Laravel
Test your knowledge
Take a quiz on this chapter to see how well you understood the concepts.
Take Quiz