Controllers in Laravel tend to grow for a very predictable reason: input handling. As a project scales, you often find yourself with duplicated validation arrays, ad-hoc input transformations (like trimming or casting), and inconsistent field naming between your web and API endpoints.
At first, you validate inside the controller, create the model, return a response.
Then the project grows, fields change, you add a JSON API, you add an admin panel… and suddenly you have:
- duplicated validation arrays in multiple endpoints,
- ad-hoc input transformations (trim, casts, defaults),
- inconsistent field naming/mapping between web and API,
- and controllers that look like “mini services”.
A pattern that helps a lot is introducing a DTO (Data Transfer Object): a typed object that represents the contract of what your endpoint accepts.
In this article, we’ll look at what DTOs are, why they matter, and how to implement them cleanly in Laravel.
What is a DTO (in a Laravel context) ?
A DTO is a simple, typed object whose job is to carry data between layers. In a Laravel context, think of it as an input contract that defines exactly what your application expects for a specific action.
In Laravel, I like to use DTOs as input objects:
- a clear list of accepted fields,
- typed properties (
string,int,bool…), - validation rules next to the data shape,
- optional normalization (trim, casting, defaults).
Think of it as:
“This is exactly the input my app expects for this action.”
Why standard validation doesn't always scale
While request()->validate() is great for small apps, it leads to several issues as you grow:
- Duplication: Store and update endpoints often copy/paste the same rules.
- Hidden Shape: Arrays don't document the input structure as well as an object does.
- Transformation Everywhere: You end up with
trim(),intval(), or default values scattered across multiple controllers. - Refactor Friction: Changing a field name means hunting through multiple files to fix strings in arrays.
The more endpoints you have, the more “input handling” becomes the main source of inconsistency.
The Practical Wins of Using DTOs
- Clear Input Contract: You can read one class and immediately see the fields, types, and rules.
- Centralized Normalization: DTOs provide a single home for trimming strings, casting booleans, or mapping legacy keys (e.g.,
full_name→name). - Thinner Controllers: Controllers return to their original purpose: orchestrating the request rather than encoding business rules.
A concrete example (before / after)
Let’s imagine a “reservation” endpoint.
BEFORE: validation + mapping inside the controller
// app/Http/Controllers/ReservationController.php
public function store()
{
$data = request()->validate([
'name' => ['required','string','max:80'],
'email' => ['required','email'],
'guests' => ['required','integer','min:1','max:12'],
]);
Reservation::create($data);
return back();
}
This works. But over time you’ll often add:
- trimming
name, - default values,
- API-specific mapping,
- duplicated rules in another endpoint.
AFTER: a DTO centralizes shape + validation
One popular approach in Laravel is Spatie Laravel Data.
// app/Data/ReservationData.php
use Spatie\LaravelData\Data;
final class ReservationData extends Data
{
public function __construct(
public string $name,
public string $email,
public int $guests,
) {}
public static function rules(): array
{
return [
'name' => ['required','string','max:80'],
'email' => ['required','email'],
'guests' => ['required','integer','min:1','max:12'],
];
}
}
Now the controller becomes a clean orchestrator:
// app/Http/Controllers/ReservationController.php
final class ReservationController
{
public function store(ReservationData $data) // auto-filled + validated
{
Reservation::create($data->all());
return back();
}
}
What changed?
- The shape is explicit and typed.
- Validation is centralized.
- The controller focuses on the action.
Where normalization fits
Sooner or later you’ll need to normalize inputs.
Examples:
- trim strings,
- cast booleans,
- convert
guestsfrom"3"to3, - map a legacy key (
full_name→name).
With DTOs, normalization can happen in a dedicated place (depending on the library/pattern you choose).
The important part is not the exact method name—it’s having a single home for it.
DTOs vs Form Requests vs Policies (how they fit together)
A clean mental model is:
- Policies → resource-level authorization (“can update THIS project?”)
- Form Requests → request validation for an action (great default approach)
- DTOs → a typed input contract used by your domain/service layer
In many real apps, a mix works well:
- Use a Form Request for action-level validation/authorization,
- Build a DTO from validated data,
- Pass the DTO to a service/use-case.
The goal is the same: keep the controller thin and the rules consistent.
A simple adoption strategy
If your project already exists, you don’t have to refactor everything at once.
Start with:
- One endpoint that changes often (usually
storeorupdate). - Extract a DTO for its inputs.
- Ensure controllers use
$data->all()(or equivalent) from the DTO. - Add a couple of tests for critical validation.
Quick checklist
- [ ] Inputs have a clear “contract” (DTO or Form Request)
- [ ] Validation is not duplicated across endpoints
- [ ] Controllers orchestrate, they don’t encode business rules
- [ ] Normalization (trim/casts/defaults) has a single home
- [ ] Authorization uses Policies (or at least is centralized)
Wrap-up
DTOs are not “mandatory” in Laravel, but they’re a great tool when your app grows:
- less duplication,
- fewer inconsistencies,
- clearer input contracts,
- smaller controllers,
- safer refactors.
If you’ve ever felt your controllers turning into mini services, DTOs are a simple pattern to bring structure back.
References
- Spatie Laravel Data documentation (request → data object, validation, etc.)
Top comments (0)