Introduction
If you've been building Laravel applications for a while, you've probably seen this before — a controller method that does way too much:
public function store(Request $request)
{
$validated = $request->validate([
'contact_id' => 'required|exists:contacts,id',
'trans_date' => 'required|date',
'due_date' => 'required|date|after_or_equal:trans_date',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.qty' => 'required|numeric|min:0.01',
// ... 20 more rules
]);
// Normalize some fields
if ($validated['currency_id'] === '') {
$validated['currency_id'] = null;
}
// Compute totals
$validated['amount'] = collect($validated['items'])->sum('amount');
// Create the record
$sale = Sale::create($validated);
// Log the action
activity()->on($sale)->log('created sale order');
return redirect()->route('sale-orders.index');
}
This controller knows too much. It validates, normalizes, computes totals, persists, and logs — all in one method. When you need to create a sale order from a different place (say, a console command or a webhook), you copy-paste the whole block. When a business rule changes, you hunt through controllers to find every place that touched the same data.
This is exactly the problem DTOs (Data Transfer Objects) solve.
A DTO is a simple PHP object whose entire job is to carry structured, typed data from one layer of your application to another. It has no behavior beyond holding that data. No database access. No HTTP knowledge. Just a clean, typed container.
In a modern Laravel application, DTOs act as the contract between layers: the HTTP layer passes a DTO to the service layer, the service passes it to an action, and the action uses it to interact with the database. Every layer speaks the same language — the DTO.
What is a DTO?
A Data Transfer Object is an object that carries data between processes or application layers. The term comes from Martin Fowler's Patterns of Enterprise Application Architecture, but the concept is simpler than the name suggests: it's just a PHP class with public properties and no business logic.
DTO vs Request vs Model vs Value Object
These four things are often confused. Here's how they differ:
| Concept | Purpose | Has Logic? | Layer |
|---|---|---|---|
| Form Request | HTTP validation only | Validation rules | HTTP layer |
| DTO | Carry typed data across layers | Minimal / none | Any |
| Model | Represent + persist a database row | Relationships, accessors | Data layer |
| Value Object | Represent a concept with identity | Equality rules | Domain layer |
A Request knows about HTTP. A Model knows about your database. A Value Object enforces business rules around a single concept (like a monetary amount or an email address). A DTO knows nothing — it just holds data. That's its strength.
When to use a DTO
- Passing validated HTTP input into a service or action
- Returning structured data from a repository or report service
- Transforming complex API responses into typed PHP objects
- Carrying configuration data through a multi-step process
When NOT to use a DTO
- Simple CRUD with a single field — a DTO adds ceremony with no benefit
- Data that never leaves a single method
- Replacing Eloquent models when the model is already the right tool
- As a replacement for Value Objects when identity/equality semantics matter
Implementing a DTO in Laravel
Let's start with a plain PHP DTO — no packages, no magic, just a class with constructor property promotion (PHP 8+).
Manual DTO
<?php
namespace App\Data;
class SaleOrderData
{
public function __construct(
public readonly int $contact_id,
public readonly string $trans_date,
public readonly string $due_date,
public readonly ?int $warehouse_id,
public readonly bool $include_tax,
public readonly float $shipping_cost,
public readonly array $items,
public readonly array $additional_discounts = [],
) {}
}
readonly properties make the DTO immutable after construction — once you build it from the request, nothing downstream can accidentally mutate it. That's exactly what you want from a data carrier.
Constructor-based validation and typing
The constructor itself enforces your type contract. If someone tries to pass a string where contact_id expects an int, PHP 8 will throw a TypeError immediately — not a mysterious null reference ten method calls later.
You can add a static factory to keep the controller clean:
public static function fromArray(array $data): self
{
return new self(
contact_id: (int) $data['contact_id'],
trans_date: $data['trans_date'],
due_date: $data['due_date'],
warehouse_id: isset($data['warehouse_id'])
? (int) $data['warehouse_id'] : null,
include_tax: (bool) ($data['include_tax'] ?? false),
shipping_cost: (float) ($data['shipping_cost'] ?? 0),
items: $data['items'],
additional_discounts: $data['additional_discounts'] ?? [],
);
}
Usage in Controller and Service
The controller's only job is to validate the raw HTTP input and hand a typed DTO to the service:
// SaleOrderController.php
public function store(Request $request): RedirectResponse
{
Gate::authorize('buat pemesanan');
$dto = SaleOrderData::fromArray(
$request->validate($this->validationRules())
);
$this->service->create($dto);
return redirect()->route('sale-orders.index')
->with('success', __('sale_order.store_success'));
}
The service orchestrates the work — it doesn't care where the data came from:
// SaleOrderService.php
public function create(SaleOrderData $data): Sale
{
return DB::transaction(function () use ($data) {
$sale = $this->createAction->execute($data);
$this->downPaymentAction->execute($sale);
return $sale;
});
}
The action is where the actual business logic lives:
// CreateSaleOrderAction.php
public function execute(SaleOrderData $data): Sale
{
$payload = $data->toArray();
// Resolve currency from contact if not provided
if (empty($payload['currency_id'])) {
$contact = Contact::with('currency')->find($data->contact_id);
$payload['currency_id'] = $contact?->currency_id
?? Currency::where('code', 'IDR')->value('id');
}
// Compute derived totals from items
$payload['amount'] = collect($data->items)->sum('amount');
$sale = $this->repository->create($payload);
SaleOrderCreatedEvent::dispatch($sale);
activity()
->performedOn($sale)
->log('membuat data pemesanan penjualan ' . $sale->transaction_number);
return $sale;
}
Notice how clean each layer is. The controller doesn't compute totals. The service doesn't validate. The action doesn't know about HTTP. Each layer has a single, clear responsibility.
Using DTO with Form Requests
Form Requests and DTOs are complementary, not competing. The Form Request handles HTTP-level concerns (authorization, validation rules, error formatting). The DTO takes the validated data and carries it forward.
// StoreSaleOrderRequest.php
class StoreSaleOrderRequest extends FormRequest
{
public function authorize(): bool
{
return Gate::check('buat pemesanan');
}
public function rules(): array
{
return [
'contact_id' => ['required', 'integer', 'exists:contacts,id'],
'trans_date' => ['required', 'date'],
'due_date' => ['required', 'date', 'after_or_equal:trans_date'],
'warehouse_id' => ['nullable', 'integer', 'exists:warehouses,id'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'exists:products,id'],
'items.*.qty' => ['required', 'numeric', 'min:0.01'],
'items.*.price' => ['required', 'numeric', 'min:0'],
];
}
}
Then in the controller, transform it into a DTO immediately:
public function store(StoreSaleOrderRequest $request): RedirectResponse
{
$dto = SaleOrderData::fromArray($request->validated());
$this->service->create($dto);
return redirect()->route('sale-orders.index')
->with('success', __('sale_order.store_success'));
}
Why combine them? The Form Request is thrown away after the controller. The DTO travels through your entire application stack. By converting early, every layer below the controller works with a typed, predictable object — not a raw array that might have null, an empty string, or a missing key depending on what the user submitted.
Advanced Approach
Immutable DTOs
The readonly keyword in PHP 8.1+ makes immutability effortless:
class PosTransactionData
{
public function __construct(
public readonly int $contact_id,
public readonly ?string $discount_type,
public readonly float $discount_amount,
public readonly array $items,
public readonly array $payments,
) {}
}
Once constructed, $dto->contact_id = 5 throws an error. This is a strong guarantee: any layer that receives the DTO knows the data hasn't been altered by a layer above it.
Using spatie/laravel-data
For production applications with many DTOs, spatie/laravel-data is worth knowing. It combines validation, casting, and transformation into a single class, eliminating a lot of boilerplate. This project uses it extensively:
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\Validation\Required;
class PosProductData extends Data
{
public function __construct(
#[Required, Max(255)]
public string $name,
#[Required, Exists('product_categories', 'id')]
public int $product_category_id,
#[Required, Numeric, Min(0)]
public float $selling_price,
) {}
}
The validateAndCreate() static method on a spatie/laravel-data class validates the input and builds the DTO in one call:
$dto = SaleOrderData::validateAndCreate($request->all());
No separate Form Request needed — the DTO carries the validation rules. You also get built-in support for casting nested arrays into typed collections, transforming the DTO back to an array, and pipeline hooks for data normalization.
Real-World Example: Creating a POS Transaction
Let's see what life looks like before and after DTOs using a real feature from a Point-of-Sale system.
Before: Everything in the controller
// PosController.php — the old way
public function checkout(Request $request)
{
// Validation buried in the controller
$request->validate([
'contact_id' => 'required|exists:contacts,id',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|gt:0',
'items.*.unit_price' => 'required|numeric|min:0',
'payments' => 'required|array|min:1',
'payments.*.amount' => 'required|numeric|gt:0',
'payments.*.payment_method_id' => 'required|exists:payment_methods,id',
]);
// Normalize discount type inline
$discount_type = $request->discount_type === ''
? null : $request->discount_type;
// Compute totals
$subtotal = collect($request->items)->sum(fn ($i) => $i['unit_price'] * $i['quantity']);
// Create sale record
$sale = Sale::create([
'contact_id' => $request->contact_id,
'discount_type' => $discount_type,
'discount_amount' => $request->discount_amount ?? 0,
'amount' => $subtotal,
'status' => 'open',
'type' => 'Faktur Penjualan',
'pos_session_id' => $request->session_id,
]);
// Create items
foreach ($request->items as $item) {
$sale->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
'total_price' => $item['unit_price'] * $item['quantity'],
]);
}
// Process payments
foreach ($request->payments as $payment) {
// ... more logic here
}
activity()->on($sale)->log('POS transaction');
return response()->json(['success' => true, 'sale_id' => $sale->id]);
}
This controller is doing five different jobs. Testing it requires a full HTTP request. Reusing any part of it from a scheduled command is impossible.
After: Clean separation with DTO
Step 1 — the DTO carries all the transaction data with full typing:
// app/Data/PosTransactionData.php
class PosTransactionData extends Data
{
public function __construct(
public int $contact_id,
public ?string $discount_type,
public float $discount_amount,
public ?int $discount_account,
public ?string $notes,
public array $items,
public array $payments,
) {}
public static function rules(): array
{
return [
'contact_id' => ['required', 'exists:contacts,id'],
'discount_type' => ['nullable', 'in:percentage,amount'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'exists:products,id'],
'items.*.quantity' => ['required', 'numeric', 'gt:0'],
'items.*.unit_price' => ['required', 'numeric', 'min:0'],
'payments' => ['required', 'array', 'min:1'],
'payments.*.payment_method_id' => ['required', 'exists:payment_methods,id'],
'payments.*.amount' => ['required', 'numeric', 'gt:0'],
];
}
public static function messages(): array
{
return [
'items.min' => 'Minimal 1 item harus ditambahkan.',
'payments.min' => 'Minimal 1 pembayaran harus ditambahkan.',
'payments.*.amount.gt' => 'Jumlah pembayaran harus lebih dari 0.',
];
}
}
Step 2 — the controller becomes trivially thin:
// PosController.php
public function checkout(PosSession $session, Request $request): JsonResponse
{
$dto = PosTransactionData::validateAndCreate($request->all());
$sale = $this->posService->createTransaction($session, $dto);
return response()->json(['success' => true, 'sale_id' => $sale->id]);
}
Step 3 — the service orchestrates the flow:
// PosService.php
public function createTransaction(PosSession $session, PosTransactionData $data): Sale
{
return $this->create_transaction_action->execute($session, $data);
}
Step 4 — the action has the actual business logic:
// CreatePosTransactionAction.php
public function execute(PosSession $session, PosTransactionData $data): Sale
{
$sale = Sale::create([
'contact_id' => $data->contact_id,
'discount_type' => $data->discount_type,
'discount_amount' => $data->discount_amount,
'pos_session_id' => $session->id,
'amount' => collect($data->items)->sum(
fn ($i) => $i['unit_price'] * $i['quantity']
),
'status' => 'open',
'type' => 'Faktur Penjualan',
]);
$this->createItems($sale, $data->items);
$this->processPayments($sale, $data->payments);
activity()
->performedOn($sale)
->log('POS transaction created: ' . $sale->transaction_number);
return $sale;
}
Now consider what you gained:
- The controller has no idea how a POS transaction works — it just validates and delegates
- The service can be called from anywhere: a console command, a webhook handler, a test
- The action can be unit tested by passing a
PosTransactionDatadirectly — no HTTP stack needed - The DTO is the documentation: anyone reading
PosTransactionDataknows exactly what a POS transaction requires
This is the actual structure used in this project. The PosService receives a PosTransactionData, passes it to CreatePosTransactionAction, which does the work. The controller at the top stays blissfully ignorant of the details.
Conclusion
DTOs are not a silver bullet, and they're not always necessary. For a two-field form updating a user's name, a DTO adds noise. But the moment your data crosses multiple layers — from HTTP to service to action to repository — a DTO pays for itself immediately.
Key takeaways:
A DTO is a promise. When CreateSaleOrderAction::execute() accepts a SaleOrderData, every caller knows exactly what to provide. No more checking if $request->get('contact_id') is present, or if the array key items exists. The DTO contract is enforced at construction time.
A DTO enables testing. You can instantiate a PosTransactionData in a test without touching HTTP, without a live request, without middleware. Just build the object and pass it to the action.
A DTO decouples your layers. The controller knows about HTTP. The model knows about the database. The DTO sits between them and knows about neither. That separation is what makes your codebase maintainable when it grows from 10 routes to 150.
A DTO makes refactoring safe. If you add a required field to SaleOrderData, PHP will immediately tell you every call site that needs updating. Try doing that with an associative array.
The pattern Controller -> Service -> Action -> Repository, with DTOs flowing between them, is what keeps a large codebase from turning into the tangled mess we started with. You get thin controllers, focused actions, and data structures that document themselves.
Start with a manual readonly class. Add spatie/laravel-data when you need validation built in. Either way, the discipline of giving your data a proper home — rather than scattering raw arrays and $request->get() calls across your codebase — is one of the highest-leverage habits you can build as a Laravel developer.
Stack: PHP 8.4 | Laravel 12 | spatie/laravel-data
Top comments (0)