Domain-Driven Design in Laravel: Actions, DTOs, and Value Objects Without Bloat
#laravel #ddd #clean-architecture #php

Domain-Driven Design in Laravel: Actions, DTOs, and Value Objects Without Bloat

4 min read Mohamed Said Mohamed Said

The Problem With "Just Use a Service Class"

Every Laravel codebase eventually grows a Services/ directory that becomes a graveyard of 800-line classes. The instinct to reach for DDD is right — but most implementations either go too far (full hexagonal ports-and-adapters) or not far enough (renaming UserService to UserAction and calling it done).

This article focuses on three concrete building blocks — Actions, DTOs, and Value Objects — and how to wire them together without inventing a framework inside your framework.


Actions: Single-Responsibility Invokables

An Action is an invokable class that encapsulates one use-case. It is not a controller, not a job, not a service. It is the verb of your domain.

// app/Domain/Billing/Actions/ChargeSubscription.php
final class ChargeSubscription
{
    public function __construct(
        private readonly PaymentGateway $gateway,
        private readonly SubscriptionRepository $subscriptions,
    ) {}

    public function __invoke(ChargeSubscriptionData $data): Receipt
    {
        $subscription = $this->subscriptions->findOrFail($data->subscriptionId);

        $charge = $this->gateway->charge(
            amount: $data->amount,
            paymentMethod: $subscription->paymentMethod,
        );

        $subscription->recordCharge($charge);

        return Receipt::from($charge);
    }
}

Resolve it via the container so dependencies are injected automatically:

// In a controller or Livewire component
app(ChargeSubscription::class)(
    new ChargeSubscriptionData(
        subscriptionId: $subscription->id,
        amount: Money::of(4900, 'USD'),
    )
);

The controller stays thin. The action is testable in isolation. No service locator smell.


DTOs: Typed Input Boundaries

A DTO (Data Transfer Object) replaces the raw array or Request object that bleeds into your domain. Use PHP 8.x readonly properties — no library required.

final readonly class ChargeSubscriptionData
{
    public function __construct(
        public int $subscriptionId,
        public Money $amount,
    ) {}

    public static function fromRequest(ChargeRequest $request): self
    {
        return new self(
            subscriptionId: (int) $request->validated('subscription_id'),
            amount: Money::of(
                (int) $request->validated('amount_cents'),
                $request->validated('currency', 'USD'),
            ),
        );
    }
}

The fromRequest factory keeps HTTP concerns out of the domain. Your action never touches $request->input(). Swap HTTP for a CLI command or a queued job without changing the action.


Value Objects: Enforce Invariants at Construction

A Value Object is immutable, identified by its value, and self-validating. Money is the classic example, but EmailAddress, Slug, Coordinates, and IpRange are equally useful.

final readonly class EmailAddress
{
    public string $value;

    public function __construct(string $value)
    {
        $normalized = mb_strtolower(trim($value));

        if (! filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidEmailAddress($normalized);
        }

        $this->value = $normalized;
    }

    public function domain(): string
    {
        return substr($this->value, strpos($this->value, '@') + 1);
    }

    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }
}

Pair it with a custom Eloquent cast so persistence is transparent:

class EmailAddressCast implements CastsAttributes
{
    public function get($model, $key, $value, $attributes): EmailAddress
    {
        return new EmailAddress($value);
    }

    public function set($model, $key, $value, $attributes): string
    {
        return $value instanceof EmailAddress ? $value->value : $value;
    }
}

Now $user->email is always a valid EmailAddress — no validation scattered across controllers.


Keeping It Lean

The trap is adding layers for their own sake. A few rules that prevent bloat:

  • One action per use-case. If two use-cases share 80% of logic, extract a private method or a shared service — don't merge the actions.
  • DTOs are dumb. No business logic inside a DTO. If you're tempted, that logic belongs in an Action or Value Object.
  • Value Objects are small. If a Value Object needs a database query to validate itself, it is not a Value Object — it is a domain service.
  • Skip the interface for every action. Interfaces add value when you have multiple implementations or need to mock at the boundary. For most actions, the concrete class is the interface.

Takeaways

  • Actions are invokable, single-purpose, and container-resolved — keep controllers and Livewire components thin.
  • DTOs create a typed boundary between HTTP/CLI and your domain; use readonly classes in PHP 8.1+.
  • Value Objects enforce invariants at construction and pair cleanly with Eloquent custom casts.
  • Resist adding abstraction until the pain is real — the goal is clarity, not pattern completeness.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Should every Laravel project use Actions, DTOs, and Value Objects?
No. A simple CRUD app with a handful of models does not need this structure. Introduce Actions when controllers grow beyond two or three responsibilities, DTOs when you find yourself passing raw arrays between layers, and Value Objects when the same validation logic appears in multiple places.
Q02 How do Actions differ from Laravel's built-in Jobs?
Jobs are designed for asynchronous, queued execution and carry serialization concerns. Actions are synchronous domain operations resolved from the container. You can dispatch an Action's logic from inside a Job, but they serve different purposes and should not be conflated.
Q03 Do I need a package like `lorisleiva/laravel-actions` to use this pattern?
No. A plain invokable class resolved via `app()` or constructor injection is sufficient. Third-party packages add conveniences like automatic route binding or job dispatching from the same class, which can be useful but also blur the single-responsibility principle.

Continue reading

More Articles

View all