DTOs in Laravel: Why I Use Them on Every Project and How
I use DTOs on every Laravel project I build. Every single one. Some developers think that's over-engineering. I think it's the bare minimum for shipping code that doesn't leak data, break frontends, o
DTOs in Laravel: Why I Use Them on Every Project and How
I use DTOs on every Laravel project I build. Every single one. Some developers think that's over-engineering. I think it's the bare minimum for shipping code that doesn't leak data, break frontends, or make the next developer cry.
Let me explain.
The Problem DTOs Solve
Without DTOs, here's what typically happens in an Inertia controller:
// The naive approach
return Inertia::render('Bookings/Show', [
'booking' => $booking->load('guest', 'hotel', 'payments'),
]);
This sends the entire Booking model — plus its loaded relationships — to the frontend as JSON. Every column. Every relationship attribute. Including things like:
hotel.stripe_secret_key(if it's on the model)guest.email(maybe you don't want this on every page)payments.*.stripe_payment_intent_id(internal data)booking.internal_notes(staff only)- Any attributes added by appends or accessors
You might think "I'll just use $hidden on the model." Sure. Until a different page needs that hidden attribute and you toggle visibility globally, breaking the first page. Or until someone adds a new column and forgets to hide it.
DTOs make the contract explicit. You define exactly what gets sent to the frontend. Nothing more, nothing less.
The Pattern
A DTO is a readonly class with typed properties and a static factory method:
// app/DTOs/BookingData.php
final readonly class BookingData
{
public function __construct(
public int $id,
public string $reference,
public string $status,
public string $guestName,
public string $guestEmail,
public string $hotelName,
public CarbonImmutable $checkIn,
public CarbonImmutable $checkOut,
public int $nights,
public int $totalPence,
public string $formattedTotal,
public ?string $specialRequests,
public CarbonImmutable $createdAt,
) {}
public static function fromModel(Booking $booking): self
{
return new self(
id: $booking->id,
reference: $booking->reference,
status: $booking->status->value,
guestName: $booking->guest->name,
guestEmail: $booking->guest->email,
hotelName: $booking->hotel->name,
checkIn: $booking->check_in,
checkOut: $booking->check_out,
nights: $booking->check_in->diffInDays($booking->check_out),
totalPence: $booking->total_pence,
formattedTotal: Number::currency($booking->total_pence / 100, 'GBP'),
specialRequests: $booking->special_requests,
createdAt: $booking->created_at,
);
}
}
Then in the controller:
return Inertia::render('Bookings/Show', [
'booking' => BookingData::fromModel($booking),
]);
That's it. The frontend receives exactly these properties and nothing else. No stripe_secret_key. No internal_notes. Just what the page needs.
Why readonly
PHP 8.2 introduced readonly classes. Every property is automatically readonly — you can't accidentally modify a DTO after creation. This matters because DTOs should be immutable. They represent a snapshot of data at a point in time. If you need different data, create a different DTO.
final readonly class BookingData
{
// All properties are automatically readonly
// $dto->guestName = 'new name'; // Error: cannot modify readonly property
}
The final keyword prevents extending the DTO. If you need a different shape, create a different class. Don't inherit from a DTO — that defeats the purpose of having an explicit contract.
The make:dto Generator
I have an Artisan command that generates DTOs. It's in every project:
php artisan make:dto BookingData --properties=id:int,reference:string,status:string
This creates a DTO with the specified properties, constructor, and a fromModel() stub. The generator saves about five minutes per DTO, which adds up quickly when you're creating ten or twenty for a project.
With the --model flag, it generates fromModel() with the correct model type:
php artisan make:dto BookingData \
--properties=id:int,reference:string,status:string,totalPence:int \
--model=Booking
Generates:
final readonly class BookingData
{
public function __construct(
public int $id,
public string $reference,
public string $status,
public int $totalPence,
) {}
public static function fromModel(Booking $booking): self
{
return new self(
id: $booking->id,
reference: $booking->reference,
status: $booking->status,
totalPence: $booking->total_pence,
);
}
}
You'll always need to customize fromModel() — adding computed properties, formatting values, handling relationships — but the generator gives you the skeleton.
Collection DTOs
For index pages that list multiple records, I use a collection pattern:
// In the controller
return Inertia::render('Bookings/Index', [
'bookings' => $bookings->map(
fn (Booking $booking) => BookingListData::fromModel($booking),
),
]);
Note: BookingListData is not the same as BookingData. The list version has fewer properties — you don't need the full detail on an index page:
final readonly class BookingListData
{
public function __construct(
public int $id,
public string $reference,
public string $status,
public string $guestName,
public string $hotelName,
public CarbonImmutable $checkIn,
public string $formattedTotal,
) {}
public static function fromModel(Booking $booking): self
{
return new self(
id: $booking->id,
reference: $booking->reference,
status: $booking->status->value,
guestName: $booking->guest->name,
hotelName: $booking->hotel->name,
checkIn: $booking->check_in,
formattedTotal: Number::currency($booking->total_pence / 100, 'GBP'),
);
}
}
Two DTOs for the same model. Different pages, different data needs, different contracts. This is the whole point.
TypeScript Type Generation
The real payoff comes when you generate TypeScript interfaces from your DTOs. I have a command that scans all DTO classes and generates a TypeScript definition file:
php artisan types:generate
This produces:
// resources/js/types/generated.d.ts
export interface BookingData {
id: number;
reference: string;
status: string;
guestName: string;
guestEmail: string;
hotelName: string;
checkIn: string;
checkOut: string;
nights: number;
totalPence: number;
formattedTotal: string;
specialRequests: string | null;
createdAt: string;
}
export interface BookingListData {
id: number;
reference: string;
status: string;
guestName: string;
hotelName: string;
checkIn: string;
formattedTotal: string;
}
Now your React component has type safety:
import type { BookingData } from '@/types/generated';
interface Props {
booking: BookingData;
}
export default function BookingShow({ booking }: Props) {
return (
<div>
<h1>{booking.reference}</h1>
<p>Guest: {booking.guestName}</p>
<p>Hotel: {booking.hotelName}</p>
<p>Check-in: {new Date(booking.checkIn).toLocaleDateString()}</p>
<p>Total: {booking.formattedTotal}</p>
{booking.specialRequests && (
<p>Notes: {booking.specialRequests}</p>
)}
</div>
);
}
If the backend DTO changes — a property is added, removed, or renamed — the generated types update and TypeScript catches every frontend reference that needs updating. No more guessing what the backend sends. No more booking.total_price when the property is actually formattedTotal.
Nested DTOs
For complex data structures, DTOs compose:
final readonly class BookingDetailData
{
public function __construct(
public int $id,
public string $reference,
public string $status,
public GuestData $guest,
public HotelData $hotel,
/** @var PaymentData[] */
public array $payments,
public CarbonImmutable $checkIn,
public CarbonImmutable $checkOut,
) {}
public static function fromModel(Booking $booking): self
{
return new self(
id: $booking->id,
reference: $booking->reference,
status: $booking->status->value,
guest: GuestData::fromModel($booking->guest),
hotel: HotelData::fromModel($booking->hotel),
payments: $booking->payments
->map(fn (Payment $p) => PaymentData::fromModel($p))
->all(),
checkIn: $booking->check_in,
checkOut: $booking->check_out,
);
}
}
The type generator handles nested DTOs and arrays of DTOs, producing correct TypeScript interfaces with proper nesting.
When DTOs Are Overkill
I said I use them on every project. But I'll admit there are cases where they feel like overhead:
Prototypes and MVPs. When you're validating an idea and the data model changes daily, maintaining DTOs adds friction. I still use them, but I don't stress about perfect property names.
Simple CRUD with no frontend. If you're building a pure API that returns Eloquent Resources, those resources serve a similar purpose. DTOs and API Resources solve the same problem differently.
Internal admin panels. If the only users are your own team and security is handled by authentication, the data leakage risk is lower. I'd still use DTOs, but I understand why some people don't.
For everything else — any project with an Inertia frontend, any project where you're delivering code to someone else, any project that will be maintained by other developers — DTOs are worth the five minutes they take to create.
The Contract Mindset
DTOs are really about contracts. The backend says "I will send you this shape of data." The frontend says "I expect this shape of data." The DTO is the contract between them.
When you change a contract, both sides know. The DTO class changes, the generated TypeScript changes, and any component that uses the old shape gets a type error. That's not overhead — that's a safety net.
After sixteen years of building web applications, I've seen what happens without contracts: silent data leakage, frontend bugs that only appear with certain data combinations, and refactoring sessions that take weeks because nobody knows what the frontend actually uses.
DTOs cost almost nothing. The problems they prevent cost a lot. That's the whole argument, and I've never seen it proven wrong.
I write about Laravel, AI tooling, and the realities of building software. More at stuartmason.co.uk.
Get the Friday email
What I shipped this week, what I learned, one useful thing.
No spam. Unsubscribe anytime. Privacy policy.