Data Transfer Object (DTO)
Laravel: Form Requests, JsonResource, Notification data carriers
Data Transfer Object (DTO)
Note: The DTO pattern is not one of the original Gang of Four (GoF) design patterns. It originates from Martin Fowler's Patterns of Enterprise Application Architecture and is widely used in Laravel and modern PHP to replace unstructured arrays.
What Is a DTO?
A Data Transfer Object is a simple object whose sole purpose is to carry data between layers of your application. It has no business logic, no behaviour beyond basic validation or transformation, and exists purely to replace the unstructured, error-prone associative arrays that PHP developers so often pass around.
Think of it like a printed payment slip at a kiosk. When a customer fills out a payment form, the teller does not scribble random notes on a napkin and pass it to the cashier. Instead, the information goes onto a standardised slip with labelled fields: payer name, amount, currency, reference number. Everyone who handles that slip knows exactly what data it carries and what each field means. A DTO is that standardised slip for your code.
The Anti-Pattern: Passing Raw Arrays
In many PHP codebases -- especially those that evolved quickly under deadline pressure -- you will see associative arrays used to shuttle data between controllers, services, and repositories:
// Controller
$data = [
'payer_name' => $request->input('name'),
'ammount' => $request->input('amount'), // typo: "ammount"
'currency' => 'PHP',
];
$paymentService->process($data);
// Service -- what keys does $data have? Who knows.
class PaymentService
{
public function process(array $data): array
{
// Hope "amount" is spelled correctly...
$total = $data['amount'] * 1.12; // KeyError if typo exists upstream
// Hope it is a number...
// Hope currency is present...
return [
'tx_id' => uniqid(),
'payer' => $data['payer_name'],
'total' => $total,
];
}
}
Problems with this approach:
- No type safety. The
$dataarray could contain anything -- strings where you expect floats, missing keys, extra keys nobody asked for. The IDE cannot help you; static analysis tools cannot help you. - Typos cause runtime errors. Misspell
amountasammountin one place and the bug silently propagates until something crashes at 2 AM in production. - No discoverability. A new developer reading
process(array $data)has no idea what keys that array should contain without tracing every call site. - Fragile refactoring. Rename a key and you must grep through the entire codebase hoping you caught every reference. Miss one and you have a silent bug.
The Fix: A Typed DTO
class CreatePaymentDTO
{
public function __construct(
public readonly string $payerName,
public readonly float $amount,
public readonly string $currency,
) {}
public static function fromArray(array $data): self
{
return new self(
payerName: $data['payer_name'],
amount: (float) $data['amount'],
currency: $data['currency'],
);
}
}
Now the service method signature tells you exactly what it expects:
class PaymentService
{
public function process(CreatePaymentDTO $payment): PaymentReceiptDTO
{
$total = $payment->amount * 1.12;
return new PaymentReceiptDTO(
transactionId: uniqid('tx_'),
payerName: $payment->payerName,
amount: $total,
currency: $payment->currency,
createdAt: date('Y-m-d H:i:s'),
);
}
}
What you gain:
- Type safety at the language level. Pass a string where a float is expected and PHP throws a TypeError immediately -- not five layers deep.
- IDE autocompletion. Type
$payment->and your editor shows every available property. No more guessing key names. - Immutability with
readonly. Once constructed, the DTO cannot be modified. This eliminates an entire class of bugs where some middleware silently mutates the data mid-flight. - Self-documenting code. The constructor signature is the documentation. New developers understand the data shape at a glance.
- Safe refactoring. Rename a property and your IDE (or static analyser) flags every usage that needs updating.
PHP 8.2 Readonly Classes
PHP 8.2 introduced the readonly class modifier, which makes every property implicitly readonly:
readonly class CreatePaymentDTO
{
public function __construct(
public string $payerName,
public float $amount,
public string $currency,
) {}
}
This is the preferred approach when every property should be immutable, which is almost always the case for DTOs.
When to Use DTOs
- Passing validated request data from a controller to a service
- Returning structured results from a service back to a controller
- Communicating between bounded contexts or modules
- Replacing arrays in method signatures where the shape of the data matters
When NOT to Use DTOs
- For a single scalar value -- just pass the value directly
- When the overhead of a class adds complexity with no benefit (e.g., a two-field internal helper)
- As a replacement for Eloquent models -- DTOs carry data, models have persistence behaviour
Key Takeaway
If you find yourself passing an associative array to a method and that method needs to know what keys exist, you need a DTO. The five minutes it takes to create a DTO class saves hours of debugging typos, missing keys, and type mismatches.
What You Learned
- A DTO replaces unstructured arrays with a typed, immutable object for passing data between layers
- PHP 8.2's
readonly classmakes DTOs naturally immutable — properties can only be set once in the constructor - Static factory methods like
fromArray()provide a clean way to create DTOs from raw input data - DTOs eliminate "magic string" key access and give you IDE autocompletion and type safety
DTOs in Laravel
Laravel uses the DTO concept in several core components, even though it does not always call them "DTOs" by name. Understanding where Laravel already applies this pattern helps you recognise when to introduce your own DTO classes.
Form Requests as Incoming DTOs
Laravel's Form Request classes validate and structure incoming HTTP data before it reaches your controller. They act as incoming DTOs -- typed containers of validated data:
// app/Http/Requests/StorePaymentRequest.php
class StorePaymentRequest extends FormRequest
{
public function rules(): array
{
return [
'payer_name' => ['required', 'string', 'max:255'],
'amount' => ['required', 'numeric', 'min:0.01'],
'currency' => ['required', 'string', 'in:PHP,USD,EUR'],
];
}
/**
* Convert validated data into a typed DTO for the service layer.
*/
public function toDTO(): CreatePaymentDTO
{
return new CreatePaymentDTO(
payerName: $this->validated('payer_name'),
amount: (float) $this->validated('amount'),
currency: $this->validated('currency'),
);
}
}
// In the controller
class PaymentController extends Controller
{
public function store(StorePaymentRequest $request, PaymentService $service): JsonResponse
{
$receipt = $service->process($request->toDTO());
return response()->json($receipt);
}
}
This keeps the controller thin and the service layer completely decoupled from HTTP concerns. The PaymentService knows nothing about requests, form fields, or validation rules -- it only knows about CreatePaymentDTO.
JsonResource as Outgoing DTOs
Laravel's JsonResource classes shape outgoing data for API responses. They serve as outgoing DTOs that transform internal domain data into a public-facing structure:
// app/Http/Resources/PaymentReceiptResource.php
class PaymentReceiptResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'transaction_id' => $this->transactionId,
'payer' => $this->payerName,
'amount' => number_format($this->amount, 2),
'currency' => $this->currency,
'processed_at' => $this->createdAt,
];
}
}
The resource controls exactly which fields are exposed and how they are formatted, preventing internal property names or sensitive data from leaking into your API responses.
Custom DTOs in Laravel Services
For non-trivial applications, creating explicit DTO classes provides the strongest type safety:
readonly class CreatePaymentDTO
{
public function __construct(
public string $payerName,
public float $amount,
public string $currency,
) {}
public static function fromArray(array $data): self
{
return new self(
payerName: $data['payer_name'],
amount: (float) $data['amount'],
currency: $data['currency'],
);
}
}
readonly class PaymentReceiptDTO
{
public function __construct(
public string $transactionId,
public string $payerName,
public float $amount,
public string $currency,
public string $createdAt,
) {}
}
These DTOs become the contract between your controller and service layers. If a property is added or renamed, the type system catches mismatches immediately.
Notification Data as DTOs
Laravel Notifications often carry structured data. Instead of passing loose arrays to toMail() or toArray(), you can pass a DTO:
class PaymentConfirmedNotification extends Notification
{
public function __construct(
private readonly PaymentReceiptDTO $receipt,
) {}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Payment Confirmed')
->line("Transaction: {$this->receipt->transactionId}")
->line("Amount: {$this->receipt->amount} {$this->receipt->currency}");
}
}
The Pattern in Practice
The common thread across Form Requests, JsonResources, and custom DTOs is the same principle: structured, typed data carriers replace unstructured arrays at layer boundaries. In a well-architected Laravel application, data flows like this:
HTTP Request
-> Form Request (validates + converts to DTO)
-> Service (accepts DTO, returns DTO)
-> Controller (wraps result in JsonResource)
-> HTTP Response
Each boundary has a clear contract. Each layer knows exactly what data it receives and what it must return. No guessing, no typos, no surprises.
This example is pre-filled and runnable. Click "Run" to see the output. Feel free to modify the code and experiment.
Complete the code below to pass all test cases. Use the hints if you get stuck.