DEV Community

Cover image for Understanding Laravel DTO: Clean Data Transfer in Modern Applications
Abd. Asis
Abd. Asis

Posted on

Understanding Laravel DTO: Clean Data Transfer in Modern Applications

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');
}
Enter fullscreen mode Exit fullscreen mode

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 = [],
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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'] ?? [],
    );
}
Enter fullscreen mode Exit fullscreen mode

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'));
}
Enter fullscreen mode Exit fullscreen mode

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;
    });
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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'],
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

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'));
}
Enter fullscreen mode Exit fullscreen mode

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,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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.',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 PosTransactionData directly — no HTTP stack needed
  • The DTO is the documentation: anyone reading PosTransactionData knows 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)