home / blog / Domain-Driven Design in Laravel: Beyond...
Laravel Jan 18, 2026

Domain-Driven Design in Laravel: Beyond CRUD

Move beyond basic CRUD and structure your Laravel applications using Domain-Driven Design principles — aggregates, value objects, repositories, and domain events.

Domain-Driven Design in Laravel: Beyond CRUD

The Problem with Default Laravel Structure

Laravel's default structure is perfect for small to medium projects. But as your application grows, the app/Models, app/Http/Controllers, and app/Services folders become dumping grounds for unrelated code. A User model might handle authentication, billing, notification preferences, and team management all at once.

Domain-Driven Design (DDD) gives us a vocabulary and set of patterns to organize code around business concepts rather than technical layers.

Restructuring: From Layers to Domains

Instead of organizing by technical concern, organize by business domain:

app/
├── Domain/
│   ├── Billing/
│   │   ├── Models/
│   │   │   ├── Invoice.php
│   │   │   └── Subscription.php
│   │   ├── Actions/
│   │   │   ├── CreateInvoice.php
│   │   │   └── ProcessPayment.php
│   │   ├── Events/
│   │   │   └── PaymentReceived.php
│   │   ├── ValueObjects/
│   │   │   └── Money.php
│   │   └── Exceptions/
│   │       └── PaymentFailedException.php
│   ├── Identity/
│   │   ├── Models/
│   │   │   ├── User.php
│   │   │   └── Team.php
│   │   └── Actions/
│   │       ├── RegisterUser.php
│   │       └── InviteTeamMember.php
│   └── Catalog/
│       ├── Models/
│       └── Actions/
├── App/
│   ├── Http/Controllers/    ← thin controllers
│   ├── Console/Commands/
│   └── Providers/

Value Objects: Encapsulating Business Rules

A value object is an immutable object that represents a concept with no identity — it's defined entirely by its attributes. Money, Email, Address are classic examples.

class Money
{
    public function __construct(
        private readonly int $amount,
        private readonly string $currency = 'USD'
    ) {
        if ($amount < 0) {
            throw new InvalidArgumentException('Amount cannot be negative');
        }
    }

    public function add(Money $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new CurrencyMismatchException();
        }

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

    public function multiply(int $factor): self
    {
        return new self($this->amount * $factor, $this->currency);
    }

    public function formatted(): string
    {
        return number_format($this->amount / 100, 2) . ' ' . $this->currency;
    }

    public function equals(Money $other): bool
    {
        return $this->amount === $other->amount
            && $this->currency === $other->currency;
    }
}

Now instead of passing raw integers around and hoping everyone remembers "is this cents or dollars?", you have a self-documenting, self-validating type.

Actions: Single-Purpose Business Operations

Actions (also called "use cases" or "commands") encapsulate a single business operation. They're the entry point to your domain logic.

class CreateInvoice
{
    public function __construct(
        private readonly InvoiceRepository $invoices,
        private readonly TaxCalculator $taxCalculator,
    ) {}

    public function execute(CreateInvoiceData $data): Invoice
    {
        $subtotal = $data->lineItems->sum(
            fn (LineItem $item) => $item->total()->amount
        );

        $tax = $this->taxCalculator->calculate(
            new Money($subtotal),
            $data->taxRegion
        );

        $invoice = $this->invoices->create([
            'customer_id' => $data->customerId,
            'subtotal' => $subtotal,
            'tax' => $tax->amount,
            'total' => $subtotal + $tax->amount,
            'due_date' => $data->dueDate,
            'status' => InvoiceStatus::Draft,
        ]);

        event(new InvoiceCreated($invoice));

        return $invoice;
    }
}

Domain Events: Decoupling Side Effects

When something important happens in your domain, fire an event. This keeps your core logic clean and lets other parts of the system react independently.

// The event — a simple data carrier
class PaymentReceived
{
    public function __construct(
        public readonly int $invoiceId,
        public readonly Money $amount,
        public readonly string $paymentMethod,
        public readonly Carbon $paidAt,
    ) {}
}

// Listeners handle side effects
class SendPaymentConfirmation
{
    public function handle(PaymentReceived $event): void
    {
        $invoice = Invoice::findOrFail($event->invoiceId);

        Mail::to($invoice->customer->email)->send(
            new PaymentConfirmationMail($invoice, $event->amount)
        );
    }
}

class UpdateAccountBalance
{
    public function handle(PaymentReceived $event): void
    {
        // Update the customer's balance
    }
}

class NotifyAccountingTeam
{
    public function handle(PaymentReceived $event): void
    {
        // Send Slack notification
    }
}

Data Transfer Objects

DTOs carry data between layers without behavior. They make your function signatures explicit and refactoring-friendly.

class CreateInvoiceData
{
    public function __construct(
        public readonly int $customerId,
        public readonly Collection $lineItems,
        public readonly string $taxRegion,
        public readonly Carbon $dueDate,
    ) {}

    public static function fromRequest(Request $request): self
    {
        return new self(
            customerId: $request->integer('customer_id'),
            lineItems: collect($request->input('line_items'))->map(
                fn ($item) => new LineItem($item['description'], $item['quantity'], $item['unit_price'])
            ),
            taxRegion: $request->string('tax_region'),
            dueDate: Carbon::parse($request->input('due_date')),
        );
    }
}

When to Apply DDD (And When Not To)

  • Use DDD when your business logic is complex, involves multiple rules, and changes frequently
  • Skip DDD for simple CRUD applications, prototypes, or admin panels
  • Start simple — you can always refactor towards DDD as complexity grows
  • Don't apply every DDD pattern — pick the ones that solve your actual problems

"The goal of DDD is not to use every pattern in the book. It's to build a shared understanding of the domain between developers and domain experts, and to reflect that understanding in code."

back to all posts