OB
All posts
Laravel DDD Architecture PHP

Domain-Driven Design in Laravel: A Practical Guide

January 10, 20243 min readby MHD Omar Bahra

Why the Default Structure Breaks Down

Laravel scaffolds you into a flat structure: app/Models, app/Http/Controllers, app/Jobs. For a CRUD app with a few models, this is fine. For a domain with real complexity — tenants, billing, permissions, workflows — it falls apart fast.

Controllers end up calling Eloquent directly. Models grow to 500 lines with methods like sendWelcomeEmail() sitting next to scopeActive(). Business rules scatter across listeners, observers, and form requests with no clear home.

Domain-Driven Design doesn't solve this by adding ceremony — it solves it by giving every concept a home that matches the business domain.

The Folder Structure

Stop organizing by technical layer. Organize by domain.

app/
  Domain/
    Billing/
      Models/
        Invoice.php
        Subscription.php
      Actions/
        CreateInvoice.php
        CancelSubscription.php
      Events/
        InvoicePaid.php
      Contracts/
        BillingGateway.php
    Users/
      Models/
        User.php
      Actions/
        RegisterUser.php
        SuspendUser.php
      DTOs/
        RegisterUserData.php
  App/              ← thin layer: HTTP, console, jobs
    Http/
      Controllers/
        BillingController.php
    Console/
      Commands/
        RenewSubscriptions.php

The Domain namespace contains pure business logic. The App namespace contains delivery mechanisms (HTTP, CLI, queues) that call into the domain.

Actions Over Fat Controllers

An Action is a single-purpose class that executes one business operation. It replaces the logic that would normally live in a controller or model.

// app/Domain/Users/Actions/RegisterUser.php
class RegisterUser
{
    public function __construct(
        private readonly UserRepository $users,
        private readonly EventDispatcher $events,
    ) {}

    public function execute(RegisterUserData $data): User
    {
        if ($this->users->existsByEmail($data->email)) {
            throw new EmailAlreadyTaken($data->email);
        }

        $user = $this->users->create([
            'name'     => $data->name,
            'email'    => $data->email,
            'password' => Hash::make($data->password),
        ]);

        $this->events->dispatch(new UserRegistered($user));

        return $user;
    }
}

The controller becomes trivially thin:

class RegisterController extends Controller
{
    public function __invoke(RegisterRequest $request, RegisterUser $action): JsonResponse
    {
        $user = $action->execute(RegisterUserData::fromRequest($request));

        return response()->json(['user' => UserResource::make($user)], 201);
    }
}

Data Transfer Objects

DTOs carry validated data between layers without relying on arrays. They make the shape of your data explicit and typesafe.

// app/Domain/Users/DTOs/RegisterUserData.php
class RegisterUserData
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
        public readonly string $password,
    ) {}

    public static function fromRequest(RegisterRequest $request): self
    {
        return new self(
            name: $request->validated('name'),
            email: $request->validated('email'),
            password: $request->validated('password'),
        );
    }
}

No more $request->all() leaking through your domain. The Action only knows about RegisterUserData, not about HTTP.

Domain Events

Domain events decouple side effects from the core action. The Action fires an event; listeners handle the consequences.

// After user registration, these run independently:
class SendWelcomeEmail implements ShouldQueue
{
    public function handle(UserRegistered $event): void
    {
        Mail::to($event->user)->send(new WelcomeMail($event->user));
    }
}

class CreateDefaultWorkspace implements ShouldQueue
{
    public function handle(UserRegistered $event): void
    {
        Workspace::createDefault($event->user);
    }
}

The RegisterUser action doesn't know about emails or workspaces. It fires UserRegistered and moves on.

When Not to Use DDD

DDD adds structure. Structure has a maintenance cost. Don't apply it to:

  • Simple CRUD sections with no business logic
  • Admin panels
  • Internal tooling

Apply it where complexity genuinely lives: billing, permissions, multi-tenancy, complex workflows. Let the rest stay simple.

The goal is not to use DDD everywhere — it's to have a place for complex logic that isn't a 600-line controller.

Enjoyed this post?

Subscribe to the newsletter

Get future posts delivered to your inbox. No spam, unsubscribe anytime.