Actions, DTOs and Value Objects: Laravel's Pragmatic Middle

9 min read

A Laravel application rarely becomes hard to change because one controller has a dramatic bug. It becomes hard to change because a business rule gradually has five homes: part in a request, part in a controller, part in a model observer, part in an event listener and part in a queued notification. Each piece looks reasonable alone, but no reader can point to the place where the use case happens.

Most applications don't need CQRS, a command bus or a hierarchy of abstract services. They need a smaller boundary: a Form Request for HTTP concerns, a DTO for use-case input, a value object for a concept with rules, and an action that names the business operation. That is Laravel's pragmatic middle: important behaviour gets a home without a second framework inside the first one.

We will create an order from a B2B catalogue. The example includes nested input, money, inventory, a transaction and a post-commit event, because that is where a vague architecture leaks.

Form Requests are the HTTP boundary

A Form Request knows the submitted field names, validation messages and authenticated user. That makes it the right place to decide whether a request has a valid shape, and the wrong dependency for the business operation itself. A scheduled import, API integration and queue job should all be able to create an order without constructing a fake HTTP request.

php
<?php

declare(strict_types=1);

namespace App\Http\Requests\Orders;

use App\Models\Order;
use Illuminate\Foundation\Http\FormRequest;

final class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Order::class);
    }

    /** @return array<string, list<string>> */
    public function rules(): array
    {
        return [
            'customer_id' => ['required', 'integer', 'exists:customers,id'],
            'currency' => ['required', 'string', 'size:3'],
            'items' => ['required', 'array', 'min:1'],
            'items.*.product_id' => ['required', 'integer', 'distinct', 'exists:products,id'],
            'items.*.quantity' => ['required', 'integer', 'min:1', 'max:1000'],
        ];
    }
}

Do not calculate a total here and do not ask the database whether stock is still available. Validation can prove that quantity is a positive integer. It cannot safely prove availability, because another request may buy the last unit after validation and before persistence. Availability is a changing business fact, so it belongs in the transaction that performs the write.

A policy answers “may this actor begin this operation?” The action must still protect facts that hold when the actor is a console command or a trusted integration. Authorization does not replace business invariants.

A DTO makes the action contract explicit

$request->validated() returns an array whose keys can be mistyped and whose nested values are invisible to a reader of the action. A DTO states what the use case accepts. It is an immutable input contract, not an Eloquent model or a persistence record.

php
<?php

declare(strict_types=1);

namespace App\Orders;

use App\Http\Requests\Orders\StoreOrderRequest;

final readonly class CreateOrderData
{
    /** @param list<CreateOrderItemData> $items */
    public function __construct(
        public int $customerId,
        public string $currency,
        public array $items,
    ) {}

    public static function fromRequest(StoreOrderRequest $request): self
    {
        $validated = $request->validated();

        return new self(
            customerId: $validated['customer_id'],
            currency: strtoupper($validated['currency']),
            items: array_map(
                fn (array $item): CreateOrderItemData => new CreateOrderItemData(
                    productId: $item['product_id'],
                    quantity: $item['quantity'],
                ),
                $validated['items'],
            ),
        );
    }
}

final readonly class CreateOrderItemData
{
    public function __construct(public int $productId, public int $quantity) {}
}

The mapping happens immediately at the boundary. The action never accepts Request, array or mixed, which means its callers cannot silently add unchecked input later. A CLI command can construct this DTO itself, and a JSON controller can map the same validated data.

Hand-written DTOs suit stable commands with two to ten fields. If every endpoint needs recursive mapping, dates, output transformation and the same data contract in several delivery channels, spatie/laravel-data can earn its dependency. Installing it only to avoid a ten-line readonly class is ceremony.

Value objects defend concepts with rules

Nothing in PHP stops you from adding dollars to an integer called total_cents, storing a negative invoice total or treating a formatted string as money. A value object owns those rules and is immutable, so it can move through the action without surprising mutation.

php
<?php

declare(strict_types=1);

namespace App\Orders;

use DomainException;

final readonly class Money
{
    public function __construct(public int $amount, public string $currency)
    {
        if ($amount < 0) {
            throw new DomainException('Money cannot be negative.');
        }

        if (preg_match('/^[A-Z]{3}$/', $currency) !== 1) {
            throw new DomainException('Money must use an ISO-like currency code.');
        }
    }

    public function multiply(int $quantity): self
    {
        if ($quantity < 1) {
            throw new DomainException('Quantity must be at least one.');
        }

        return new self($this->amount * $quantity, $this->currency);
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new DomainException('Cannot add different currencies.');
        }

        return new self($this->amount + $other->amount, $this->currency);
    }
}

Persist integer minor units, never binary floats, for money. This object intentionally does not calculate VAT, fetch exchange rates or format an invoice. Those are separate policies.

Not every scalar deserves a class. A one-time sort_direction, already constrained by request validation, does not need SortDirection. Introduce a value object when invalid states are costly, a rule is repeated in several places, or the type gives a business idea a name. Otherwise a native enum, a cast or a well-named primitive is clearer.

An action owns one workflow and its transaction

An action coordinates one business operation, and its public method says what it does. CreateOrder locks products, checks stock, writes the order and dispatches an event only after the transaction commits.

php
<?php

declare(strict_types=1);

namespace App\Actions\Orders;

use App\Events\OrderPlaced;
use App\Models\Order;
use App\Models\Product;
use App\Orders\CreateOrderData;
use App\Orders\Money;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

final class CreateOrder
{
    public function handle(CreateOrderData $data): Order
    {
        return DB::transaction(function () use ($data): Order {
            $products = Product::query()
                ->whereIn('id', array_map(fn ($item): int => $item->productId, $data->items))
                ->orderBy('id')
                ->lockForUpdate()
                ->get()
                ->keyBy('id');

            $order = Order::query()->create([
                'customer_id' => $data->customerId,
                'currency' => $data->currency,
                'total_cents' => 0,
            ]);

            $total = new Money(0, $data->currency);

            foreach ($data->items as $item) {
                $product = $products->get($item->productId);

                if ($product === null || $product->stock < $item->quantity) {
                    throw ValidationException::withMessages([
                        'items' => ['One or more products are no longer available in the requested quantity.'],
                    ]);
                }

                $lineTotal = (new Money($product->price_cents, $data->currency))->multiply($item->quantity);

                $order->items()->create([
                    'product_id' => $product->id,
                    'quantity' => $item->quantity,
                    'unit_price_cents' => $product->price_cents,
                    'line_total_cents' => $lineTotal->amount,
                ]);

                $product->decrement('stock', $item->quantity);
                $total = $total->add($lineTotal);
            }

            $order->update(['total_cents' => $total->amount]);
            OrderPlaced::dispatch($order);

            return $order->refresh();
        }, attempts: 3);
    }
}

The event declares ShouldDispatchAfterCommit itself, so no caller has to remember a post-commit method.

php
<?php

declare(strict_types=1);

namespace App\Events;

use App\Models\Order;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

final class OrderPlaced implements ShouldDispatchAfterCommit
{
    use Dispatchable;
    use SerializesModels;

    public function __construct(public Order $order) {}
}

The action owns the transaction because it coordinates the writes that must succeed or fail together. A controller should not open one transaction while helpers open more “just in case”. Nor should the action return redirects, translated flash messages or Inertia responses: those are delivery decisions.

lockForUpdate() fits when inventory is the contention point and the database is the source of truth. It can add waiting and deadlocks, so use a consistent lock order, keep the transaction short and avoid an HTTP call inside it. A high-volume reservation system may need an atomic conditional update, a dedicated inventory service or an asynchronous reservation workflow instead.

The controller becomes deliberately uninteresting

php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Orders;

use App\Actions\Orders\CreateOrder;
use App\Http\Controllers\Controller;
use App\Http\Requests\Orders\StoreOrderRequest;
use App\Orders\CreateOrderData;
use Illuminate\Http\RedirectResponse;

final class StoreOrderController extends Controller
{
    public function __invoke(StoreOrderRequest $request, CreateOrder $createOrder): RedirectResponse
    {
        $order = $createOrder->handle(CreateOrderData::fromRequest($request));

        return to_route('orders.show', $order)->with('success', 'Order created.');
    }
}

The short controller shows that input validation, business orchestration and response formatting are separate. A CLI command can call the action; an API controller can return a resource.

Test the business boundary, not Eloquent mocks

The action test should use a real database. Mocking Eloquent chains mostly tests the mock and misses relationship, transaction and event-timing mistakes. Keep request validation in a feature test; test the action's outcome separately.

php
<?php

use App\Actions\Orders\CreateOrder;
use App\Events\OrderPlaced;
use App\Models\Customer;
use App\Models\Product;
use App\Orders\CreateOrderData;
use App\Orders\CreateOrderItemData;
use Illuminate\Support\Facades\Event;
use Illuminate\Validation\ValidationException;

it('creates an order and decrements stock', function () {
    Event::fake();
    $customer = Customer::factory()->create();
    $product = Product::factory()->create(['stock' => 4, 'price_cents' => 1250]);

    $order = app(CreateOrder::class)->handle(new CreateOrderData(
        customerId: $customer->id,
        currency: 'PLN',
        items: [new CreateOrderItemData($product->id, 2)],
    ));

    expect($order->total_cents)->toBe(2500);
    expect($product->refresh()->stock)->toBe(2);
    expect($order->items)->toHaveCount(1);
    Event::assertDispatched(OrderPlaced::class);
});

it('does not create a partial order when stock is insufficient', function () {
    $customer = Customer::factory()->create();
    $product = Product::factory()->create(['stock' => 1, 'price_cents' => 1250]);

    app(CreateOrder::class)->handle(new CreateOrderData(
        customerId: $customer->id,
        currency: 'PLN',
        items: [new CreateOrderItemData($product->id, 2)],
    ));
})->throws(ValidationException::class);

In the real suite, use the project's database-refresh trait and assert that no order remains after the exception. Add a request test for duplicate products and a policy test for unauthorized users. If concurrent stock is important, test on the production database engine too; SQLite has different locking characteristics.

What this avoids, and when not to use it

One trap is a “DTO” for every database table, copied through controller, service, event and job. That is data shuffling, not a boundary. Another is a BaseAction with execute, run and hidden middleware hooks. Callers should see what a class does: CreateOrder::handle() plus ordinary dependency injection is enough.

Do not place domain rules only in a Form Request, because imports and queued jobs bypass it. Do not let an action accept Request, array or mixed, because its contract becomes invisible. Do not make Money silently exchange currencies. Do not send emails or webhooks before commit, because a rollback would leave the outside world believing in an order that does not exist.

For plain CRUD that updates one model without cross-model invariants, Laravel's usual controller, request and model are enough. For a reporting query, an Eloquent scope or dedicated query object is more honest than an action. For a long-running workflow across services, retries and human approval, use an explicit state machine or queue-driven process. Not every method needs four classes. Use this middle where ordinary Laravel starts to blur the paths that matter.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.