DEV Community

A0mineTV
A0mineTV

Posted on

Building a Scalable Laravel Application with DDD and CQRS Architecture

As applications grow in complexity, maintaining clean, scalable, and testable code becomes increasingly challenging. In this article, I'll show you how to implement Domain-Driven Design (DDD) and Command Query Responsibility Segregation (CQRS) patterns in Laravel to build a robust, enterprise-grade application.

🎯 What We'll Build

We'll create a task management system that demonstrates:

  • Clear separation of concerns with DDD layers
  • Write and Read model separation with CQRS
  • Event-driven architecture
  • Testable, maintainable code

📚 Table of Contents

  1. Understanding the Architecture
  2. Project Structure
  3. Domain Layer - The Heart of Your Business
  4. Application Layer - Orchestrating Use Cases
  5. Infrastructure Layer - Technical Implementation
  6. Interfaces Layer - User-Facing Endpoints
  7. Wiring Everything Together
  8. Testing the Application
  9. Benefits and Trade-offs

Understanding the Architecture

Why DDD + CQRS ?

Domain-Driven Design helps us organize code around business concepts, making it easier to understand and maintain. CQRS separates read and write operations, allowing us to optimize each independently.

Architecture Overview

┌─────────────────────────────────────────┐
│         Interfaces Layer                │
│   (HTTP Controllers, CLI Commands)      │
│            ↓ ↑                          │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│       Application Layer                 │
│    Commands → CommandBus → Handlers     │
│    Queries  → QueryBus   → Handlers     │
│            ↓                            │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│         Domain Layer                    │
│  Entities, Value Objects, Interfaces    │
│            ↓                            │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│      Infrastructure Layer               │
│   Write Model ←→ Read Model             │
│   (Eloquent)     (Optimized Views)      │
└─────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key Principles:

  • Write Model: Source of truth for data modifications
  • Read Model: Optimized denormalized views for queries
  • Event-Driven: Changes in write model automatically update read model

Project Structure

Here's our complete directory structure:

app/
├── Domain/                    # Pure business logic
│   └── Task/
│       ├── Entities/
│       │   ├── Task.php
│       │   └── TaskId.php
│       ├── ValueObjects/
│       │   ├── Title.php
│       │   └── Priority.php
│       ├── Repositories/
│       │   └── TaskRepository.php      # Interface (Port)
│       ├── Services/
│       │   └── TaskPolicyService.php
│       └── Events/
│           └── TaskCreated.php
│
├── Application/               # Use cases orchestration
│   ├── Shared/
│   │   └── Bus/
│   │       ├── CommandBus.php
│   │       ├── QueryBus.php
│   │       ├── SimpleCommandBus.php
│   │       └── SimpleQueryBus.php
│   └── Task/
│       ├── Commands/
│       │   ├── CreateTask.php
│       │   └── UpdateTaskTitle.php
│       ├── CommandHandlers/
│       │   ├── CreateTaskHandler.php
│       │   └── UpdateTaskTitleHandler.php
│       ├── Queries/
│       │   ├── ListTasks.php
│       │   └── GetTaskById.php
│       ├── QueryHandlers/
│       │   ├── ListTasksHandler.php
│       │   └── GetTaskByIdHandler.php
│       ├── Events/
│       │   └── TaskWasCreated.php
│       ├── Projectors/
│       │   └── TaskProjector.php
│       └── Console/
│           └── RebuildTaskReadModel.php
│
├── Infrastructure/            # Technical implementations
│   ├── Persistence/
│   │   ├── Eloquent/
│   │   │   ├── Models/
│   │   │   │   └── TaskModel.php        # Write Model
│   │   │   └── Repositories/
│   │   │       └── EloquentTaskRepository.php
│   │   └── ReadModel/
│   │       ├── Eloquent/
│   │       │   └── TaskViewModel.php    # Read Model
│   │       └── DBAL/
│   │           └── TaskViewQuery.php
│   ├── Eventing/
│   │   └── Listeners/
│   │       └── OnTaskWasCreated.php
│   ├── Mappers/
│   │   └── TaskMapper.php
│   └── Providers/
│       ├── DomainServiceProvider.php
│       └── CqrsServiceProvider.php
│
└── Interfaces/                # User-facing layer
    ├── Http/
    │   ├── Controllers/
    │   │   └── TaskController.php
    │   ├── Requests/
    │   │   └── Task/
    │   │       ├── CreateTaskRequest.php
    │   │       ├── UpdateTaskRequest.php
    │   │       └── FilterTasksRequest.php
    │   └── Resources/
    │       └── Task/
    │           ├── TaskResource.php
    │           └── TaskCollection.php
    └── CLI/
        └── SyncTasksCommand.php
Enter fullscreen mode Exit fullscreen mode

Domain Layer - The Heart of Your Business

The Domain layer contains pure business logic with zero dependencies on frameworks or infrastructure.

1. Value Objects: Immutable Business Concepts

Value Objects encapsulate validation and business rules. They represent concepts in your domain that are defined by their attributes rather than their identity.

<?php

declare(strict_types=1);

namespace App\Domain\Task\ValueObjects;

use InvalidArgumentException;

final class Title
{
    // Private property - can only be set internally
    private string $value;

    // Private constructor prevents direct instantiation
    // Forces users to use the fromString() factory method
    private function __construct(string $value)
    {
        // Step 1: Clean the input by removing whitespace
        $trimmed = trim($value);

        // Step 2: Business Rule #1 - Title must not be empty
        // This ensures we never have tasks without titles
        if (empty($trimmed)) {
            throw new InvalidArgumentException('Title cannot be empty');
        }

        // Step 3: Business Rule #2 - Title has a maximum length
        // Prevents database overflow and maintains UI consistency
        if (mb_strlen($trimmed) > 255) {
            throw new InvalidArgumentException('Title cannot exceed 255 characters');
        }

        // Step 4: If all validations pass, store the clean value
        $this->value = $trimmed;
    }

    // Factory method - the ONLY way to create a Title
    // Returns a new Title instance or throws an exception if invalid
    public static function fromString(string $value): self
    {
        return new self($value);
    }

    // Getter method - returns the validated string value
    public function toString(): string
    {
        return $this->value;
    }

    // Equality check - compares two Title objects by their value
    // Since Value Objects have no identity, equality is based on attributes
    public function equals(Title $other): bool
    {
        return $this->value === $other->value;
    }
}
Enter fullscreen mode Exit fullscreen mode

How it works:

  1. Creation: Title::fromString("Buy groceries")

    • Calls the private constructor
    • Validates the input
    • Returns a valid Title or throws an exception
  2. Validation happens at creation time:

   $title = Title::fromString(""); // ❌ Throws: Title cannot be empty
   $title = Title::fromString("A very long title..."); // ❌ Throws if > 255 chars
   $title = Title::fromString("  Valid Title  "); // ✅ Creates Title with "Valid Title"
Enter fullscreen mode Exit fullscreen mode
  1. Immutability: Once created, the value cannot be changed
   $title = Title::fromString("Original");
   // No way to modify $title->value from outside!
   // To "change" a title, you create a new one
   $newTitle = Title::fromString("Updated");
Enter fullscreen mode Exit fullscreen mode
  1. Comparison:
   $title1 = Title::fromString("Buy groceries");
   $title2 = Title::fromString("Buy groceries");
   $title1->equals($title2); // Returns true - same value
Enter fullscreen mode Exit fullscreen mode

Key Features:

  • Immutable - Created once, never changed (no setters!)
  • Self-validating - Invalid titles cannot exist
  • Encapsulates business rules - All title rules in one place
  • No framework dependencies - Pure PHP object
  • Type-safe - Can't accidentally pass a raw string where Title is expected

2. Entities: Objects with Identity

Entities are the core business objects in your domain. Unlike Value Objects, entities have a unique identity and can change over time while maintaining that identity.

<?php

declare(strict_types=1);

namespace App\Domain\Task\Entities;

use App\Domain\Task\ValueObjects\Priority;
use App\Domain\Task\ValueObjects\Title;
use DateTimeImmutable;

final class Task
{
    // Entity properties - using Value Objects ensures validity
    private TaskId $id;              // Unique identifier
    private Title $title;            // Value Object - always valid
    private Priority $priority;      // Value Object - always valid
    private bool $isCompleted;       // Simple boolean state
    private DateTimeImmutable $createdAt;  // Immutable timestamp
    private DateTimeImmutable $updatedAt;  // Tracks modifications

    // Private constructor - prevents invalid Task creation
    // Only accessible through factory methods (create, reconstitute)
    private function __construct(
        TaskId $id,
        Title $title,
        Priority $priority,
        bool $isCompleted,
        DateTimeImmutable $createdAt,
        DateTimeImmutable $updatedAt
    ) {
        $this->id = $id;
        $this->title = $title;
        $this->priority = $priority;
        $this->isCompleted = $isCompleted;
        $this->createdAt = $createdAt;
        $this->updatedAt = $updatedAt;
    }

    // Factory method for creating NEW tasks
    // Enforces business rules: tasks always start incomplete
    public static function create(
        TaskId $id,
        Title $title,
        Priority $priority
    ): self {
        $now = new DateTimeImmutable();

        return new self(
            $id,
            $title,
            $priority,
            false,  // ⚠️ Business Rule: New tasks are never completed
            $now,   // Set creation time
            $now    // Initially, updated = created
        );
    }

    // Behavior method - encapsulates the business logic for updating title
    public function updateTitle(Title $newTitle): void
    {
        // Only update if the title actually changed
        // This prevents unnecessary database writes
        if (!$this->title->equals($newTitle)) {
            $this->title = $newTitle;
            $this->updatedAt = new DateTimeImmutable();  // Track when changed
        }
    }

    // Behavior method - encapsulates the business logic for completing a task
    public function markAsCompleted(): void
    {
        // Idempotent operation - safe to call multiple times
        if (!$this->isCompleted) {
            $this->isCompleted = true;
            $this->updatedAt = new DateTimeImmutable();  // Track completion time
        }
    }

    // Getters - provide read-only access to entity state
    // Return Value Objects, not primitives, maintaining type safety
    public function id(): TaskId { return $this->id; }
    public function title(): Title { return $this->title; }
    public function priority(): Priority { return $this->priority; }
    public function isCompleted(): bool { return $this->isCompleted; }
    public function createdAt(): DateTimeImmutable { return $this->createdAt; }
    public function updatedAt(): DateTimeImmutable { return $this->updatedAt; }
}
Enter fullscreen mode Exit fullscreen mode

How it works in practice:

// ✅ Creating a new task (via factory method)
$taskId = TaskId::generate();
$title = Title::fromString("Implement DDD");
$priority = Priority::high();

$task = Task::create($taskId, $title, $priority);
// Result: New task with isCompleted=false, timestamps set

// ✅ Modifying the task through behavior methods
$newTitle = Title::fromString("Implement DDD and CQRS");
$task->updateTitle($newTitle);
// Result: Title updated, updatedAt changed

// ✅ Completing the task
$task->markAsCompleted();
// Result: isCompleted=true, updatedAt changed

// ✅ Idempotent - safe to call again
$task->markAsCompleted();
// Result: No change, already completed

// ❌ CANNOT do this - no direct setters!
// $task->title = "New Title";  // Compilation error!
// $task->isCompleted = true;   // Compilation error!
Enter fullscreen mode Exit fullscreen mode

Key Differences: Entity vs Value Object

Aspect Entity (Task) Value Object (Title)
Identity Has unique ID No identity, defined by value
Mutability Can change over time Immutable
Equality Compared by ID Compared by value
Lifecycle Tracked (created, updated) No lifecycle
Example Two tasks with same title are DIFFERENT Two titles with same text are IDENTICAL

Entity Lifecycle Example:

// Time: 10:00 AM
$task = Task::create(
    TaskId::fromString("123"),
    Title::fromString("Deploy app"),
    Priority::high()
);
// ID: 123, isCompleted: false, createdAt: 10:00, updatedAt: 10:00

// Time: 11:00 AM - Update title
$task->updateTitle(Title::fromString("Deploy app to production"));
// ID: 123 (same!), isCompleted: false, createdAt: 10:00, updatedAt: 11:00

// Time: 2:00 PM - Complete task
$task->markAsCompleted();
// ID: 123 (still same!), isCompleted: true, createdAt: 10:00, updatedAt: 14:00
Enter fullscreen mode Exit fullscreen mode

Why This Matters:

  1. Business logic is centralized - All task rules live in the Task entity
  2. Invariants are protected - Can't create a task that violates business rules
  3. Testable - No database or framework needed to test business logic
  4. Clear API - Methods express business intent (markAsCompleted, not setCompleted)
  5. Audit trail - Automatic tracking of when changes occurred

3. Repository Interface: Persistence Contract

Define what you need, not how it's implemented:

<?php

declare(strict_types=1);

namespace App\Domain\Task\Repositories;

use App\Domain\Task\Entities\Task;
use App\Domain\Task\Entities\TaskId;

interface TaskRepository
{
    public function save(Task $task): void;

    public function findById(TaskId $id): ?Task;

    public function delete(TaskId $id): void;

    public function nextIdentity(): TaskId;
}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Domain doesn't depend on Eloquent
  • Easy to swap implementations
  • Testable with in-memory repositories

Application Layer - Orchestrating Use Cases

The Application layer implements use cases using CQRS pattern.

1. Command Bus & Commands

Commands represent intentions to change the system:

<?php

namespace App\Application\Task\Commands;

final class CreateTask
{
    public function __construct(
        public readonly string $id,
        public readonly string $title,
        public readonly string $priority
    ) {
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Command Handlers

Command Handlers are the orchestrators - they execute the business logic by coordinating domain objects and infrastructure.

<?php

namespace App\Application\Task\CommandHandlers;

use App\Application\Task\Commands\CreateTask;
use App\Application\Task\Events\TaskWasCreated;
use App\Domain\Task\Entities\Task;
use App\Domain\Task\Entities\TaskId;
use App\Domain\Task\Repositories\TaskRepository;
use App\Domain\Task\ValueObjects\Priority;
use App\Domain\Task\ValueObjects\Title;
use Illuminate\Contracts\Events\Dispatcher;

final class CreateTaskHandler
{
    // Dependencies injected via constructor
    // TaskRepository: interface from Domain layer (port)
    // Dispatcher: Laravel's event system (infrastructure)
    public function __construct(
        private readonly TaskRepository $taskRepository,
        private readonly Dispatcher $eventDispatcher
    ) {
    }

    // This method is called by the CommandBus
    // It receives a CreateTask command containing raw user input
    public function handle(CreateTask $command): void
    {
        // STEP 1: Transform raw input into Domain objects
        // ------------------------------------------------
        // Convert string UUID to TaskId Value Object
        $taskId = TaskId::fromString($command->id);

        // Convert raw string to Title Value Object
        // ⚠️ If title is invalid, this throws an exception
        //    The controller catches it and returns a 400 error
        $title = Title::fromString($command->title);

        // Convert string to Priority Value Object
        // ⚠️ Throws if priority is not "low", "medium", or "high"
        $priority = Priority::fromString($command->priority);

        // STEP 2: Execute Business Logic
        // -------------------------------
        // Call the entity's factory method
        // This enforces domain rules (e.g., task starts incomplete)
        $task = Task::create($taskId, $title, $priority);
        // Result: Task object with:
        //   - id: $taskId
        //   - title: $title (validated)
        //   - priority: $priority (validated)
        //   - isCompleted: false (business rule!)
        //   - createdAt: now()
        //   - updatedAt: now()

        // STEP 3: Persist to Write Model (source of truth)
        // -------------------------------------------------
        // Save the domain entity to the database
        // The repository handles:
        //   - Converting Task entity → database row
        //   - Running the SQL INSERT or UPDATE
        //   - Transaction management (if configured)
        $this->taskRepository->save($task);
        // Database now has: tasks table with new row

        // STEP 4: Emit Application Event for Read Model
        // ----------------------------------------------
        // Create an application-level event
        // This is NOT a domain event - it's for projecting to read model
        $this->eventDispatcher->dispatch(
            new TaskWasCreated(
                $task->id()->toString(),
                $task->title()->toString(),
                $task->priority()->toString(),
                $task->createdAt()
            )
        );
        // This triggers TaskProjector which updates task_views table
        // Result: Read model synchronized with write model
    }
}
Enter fullscreen mode Exit fullscreen mode

Complete Flow Example:

// USER REQUEST arrives
POST /api/v1/tasks
{
  "title": "Write article on DDD",
  "priority": "high"
}

// ↓ Controller receives request

// ↓ Controller generates ID and creates Command
$taskId = $repository->nextIdentity(); // Generate UUID
$command = new CreateTask(
    id: "123e4567-e89b-12d3-a456-426614174000",
    title: "Write article on DDD",
    priority: "high"
);

// ↓ Controller dispatches Command to CommandBus
$commandBus->dispatch($command);

// ↓ CommandBus routes to CreateTaskHandler
$handler = app()->make(CreateTaskHandler::class);
$handler->handle($command);

// INSIDE THE HANDLER:
// -------------------

// STEP 1: Build Value Objects
$taskId = TaskId::fromString("123e4567...");
$title = Title::fromString("Write article on DDD");
$priority = Priority::fromString("high");

// STEP 2: Create Domain Entity
$task = Task::create($taskId, $title, $priority);
// Task {
//   id: TaskId("123e4567..."),
//   title: Title("Write article on DDD"),
//   priority: Priority("high"),
//   isCompleted: false,  ← Business rule!
//   createdAt: 2025-10-12 14:30:00,
//   updatedAt: 2025-10-12 14:30:00
// }

// STEP 3: Save to Write Model
$this->taskRepository->save($task);
// SQL executed:
// INSERT INTO tasks (id, title, priority, is_completed, created_at, updated_at)
// VALUES ('123e4567...', 'Write article on DDD', 'high', false, '2025-10-12 14:30:00', '2025-10-12 14:30:00');

// STEP 4: Emit Event
$event = new TaskWasCreated(
    taskId: "123e4567...",
    title: "Write article on DDD",
    priority: "high",
    occurredAt: 2025-10-12 14:30:00
);
$this->eventDispatcher->dispatch($event);

// ↓ Event triggers TaskProjector

// INSIDE TaskProjector:
// ---------------------
TaskViewModel::create([
    'id' => "123e4567...",
    'title' => "Write article on DDD",
    'priority' => "high",
    'is_completed' => false,
    'created_at' => 2025-10-12 14:30:00,
    'updated_at' => 2025-10-12 14:30:00,
]);
// SQL executed:
// INSERT INTO task_views (id, title, priority, is_completed, created_at, updated_at)
// VALUES ('123e4567...', 'Write article on DDD', 'high', false, '2025-10-12 14:30:00', '2025-10-12 14:30:00');

// ↓ Controller receives success

// USER RESPONSE
HTTP 201 Created
{
  "message": "Task created successfully",
  "id": "123e4567-e89b-12d3-a456-426614174000"
}
Enter fullscreen mode Exit fullscreen mode

Why This Pattern?

  1. Separation of Concerns:

    • Controller: HTTP handling
    • Command: Data transfer
    • Handler: Business orchestration
    • Entity: Business rules
    • Repository: Persistence
  2. Testability:

   // Easy to test without HTTP, database, or framework
   $handler = new CreateTaskHandler($mockRepository, $mockDispatcher);
   $handler->handle(new CreateTask("id", "title", "high"));
Enter fullscreen mode Exit fullscreen mode
  1. Consistency:

    • Write model saved first (source of truth)
    • Event emitted after (eventual consistency for read model)
    • If event fails, read model can be rebuilt from write model
  2. Flexibility:

    • Easy to add logging, validation, authorization as middleware
    • Can queue commands for async processing
    • Multiple handlers can listen to same event

3. Query Bus & Queries

Queries represent requests for data:

<?php

namespace App\Application\Task\Queries;

final class ListTasks
{
    public function __construct(
        public readonly ?string $priority = null,
        public readonly ?bool $isCompleted = null,
        public readonly int $page = 1,
        public readonly int $perPage = 15
    ) {
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Query Handlers

Query Handlers are read-only operations that fetch data from the optimized read model. They never modify data.

<?php

namespace App\Application\Task\QueryHandlers;

use App\Application\Task\Queries\ListTasks;
use App\Infrastructure\Persistence\ReadModel\Eloquent\TaskViewModel;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;

final class ListTasksHandler
{
    // This handler receives a ListTasks query object
    // It returns paginated results directly (no domain entities needed for reads!)
    public function handle(ListTasks $query): LengthAwarePaginator
    {
        // ⚠️ IMPORTANT: Query TaskViewModel (read model), NOT TaskModel (write model)
        // The read model is optimized with indexes for fast queries
        $queryBuilder = TaskViewModel::query();

        // Apply filters dynamically based on query parameters
        // Only add WHERE clauses if filters are provided
        if ($query->priority !== null) {
            $queryBuilder->where('priority', $query->priority);
            // SQL: ... WHERE priority = 'high'
        }

        if ($query->isCompleted !== null) {
            $queryBuilder->where('is_completed', $query->isCompleted);
            // SQL: ... AND is_completed = false
        }

        // Return paginated results
        // Laravel's paginate() method:
        //   - Counts total matching records
        //   - Fetches only the requested page
        //   - Includes pagination metadata
        return $queryBuilder
            ->orderBy('created_at', 'desc')  // Newest first
            ->paginate($query->perPage, ['*'], 'page', $query->page);
        // SQL executed (example for page 1, 15 per page):
        // SELECT * FROM task_views
        // WHERE priority = 'high' AND is_completed = false
        // ORDER BY created_at DESC
        // LIMIT 15 OFFSET 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

Complete Query Flow:

// USER REQUEST
GET /api/v1/tasks?priority=high&is_completed=false&page=1&per_page=15

// ↓ Controller receives request

// ↓ Controller creates Query object
$query = new ListTasks(
    priority: 'high',
    isCompleted: false,
    page: 1,
    perPage: 15
);

// ↓ Controller asks QueryBus
$tasks = $queryBus->ask($query);

// ↓ QueryBus routes to ListTasksHandler
$handler = app()->make(ListTasksHandler::class);
$result = $handler->handle($query);

// INSIDE THE HANDLER:
// -------------------

// Build query on READ MODEL (task_views table)
$queryBuilder = TaskViewModel::query();
// SELECT * FROM task_views

// Apply filters
$queryBuilder->where('priority', 'high');
// WHERE priority = 'high'

$queryBuilder->where('is_completed', false);
// AND is_completed = false

// Execute with pagination
$result = $queryBuilder
    ->orderBy('created_at', 'desc')
    ->paginate(15, ['*'], 'page', 1);

// SQL EXECUTED:
// -------------
// Query 1: Count total matching records
// SELECT COUNT(*) FROM task_views
// WHERE priority = 'high' AND is_completed = false;
// Result: 42

// Query 2: Fetch page 1
// SELECT * FROM task_views
// WHERE priority = 'high' AND is_completed = false
// ORDER BY created_at DESC
// LIMIT 15 OFFSET 0;
// Result: 15 task records

// ↓ Return to controller

// CONTROLLER formats response
return new TaskCollection($tasks);

// USER RESPONSE
{
  "data": [
    {
      "id": "123...",
      "title": "Deploy to production",
      "priority": "high",
      "is_completed": false,
      "created_at": "2025-10-12T14:30:00Z"
    },
    // ... 14 more tasks
  ],
  "meta": {
    "total": 42,
    "current_page": 1,
    "per_page": 15,
    "last_page": 3
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Differences: Command vs Query

Aspect Command (Write) Query (Read)
Purpose Modify state Retrieve data
Source Write Model (tasks) Read Model (task_views)
Returns void or success/failure Data (entities, DTOs, arrays)
Side Effects Yes (creates, updates, deletes) No (read-only)
Uses Domain Logic Yes (entities, value objects) No (direct database read)
Example CreateTask, UpdateTask ListTasks, GetTaskById
Can Fail Business rule violations Only technical errors

Why Read from a Separate Table?

  1. Performance:
   // Read model has optimized indexes
   CREATE INDEX idx_priority ON task_views(priority);
   CREATE INDEX idx_completed ON task_views(is_completed);
   CREATE INDEX idx_priority_completed ON task_views(priority, is_completed);

   // Query is FAST because indexes cover the WHERE clause
   SELECT * FROM task_views
   WHERE priority = 'high' AND is_completed = false;
Enter fullscreen mode Exit fullscreen mode
  1. Denormalization:
   // Read model can include precomputed or joined data
   class TaskViewModel extends Model
   {
       // Can have extra fields not in write model:
       protected $fillable = [
           'id',
           'title',
           'priority',
           'is_completed',
           'user_name',        // ← Denormalized from users table
           'days_overdue',     // ← Computed field
           'tag_count',        // ← Aggregated from tags
       ];
   }
Enter fullscreen mode Exit fullscreen mode
  1. Multiple Read Models:
   // Different views for different queries
   TaskViewModel         // General listing
   TaskStatisticsView    // Dashboard stats
   UserTasksView         // User-specific view with user data joined
   OverdueTasksView      // Pre-filtered for overdue tasks
Enter fullscreen mode Exit fullscreen mode

Notice the Difference:

// ❌ WRONG: Querying from write model
// This defeats the purpose of CQRS
$tasks = TaskModel::where('priority', 'high')->get();

// ✅ CORRECT: Querying from read model
// Optimized, indexed, potentially denormalized
$tasks = TaskViewModel::where('priority', 'high')->get();
Enter fullscreen mode Exit fullscreen mode

Why This Pattern?

  1. Scalability: Read model can be on a different database server
  2. Performance: Optimized indexes for specific queries
  3. Flexibility: Add new read models without touching write side
  4. Simplicity: No need to convert Eloquent → Domain entities for reads

5. Projectors: Keeping Read Model in Sync

Projectors listen to events and update the read model:

<?php

namespace App\Application\Task\Projectors;

use App\Application\Task\Events\TaskWasCreated;
use App\Infrastructure\Persistence\ReadModel\Eloquent\TaskViewModel;

final class TaskProjector
{
    public function onTaskWasCreated(TaskWasCreated $event): void
    {
        TaskViewModel::create([
            'id' => $event->taskId,
            'title' => $event->title,
            'priority' => $event->priority,
            'is_completed' => false,
            'created_at' => $event->occurredAt,
            'updated_at' => $event->occurredAt,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Infrastructure Layer - Technical Implementation

1. Write Model (Source of Truth)

<?php

namespace App\Infrastructure\Persistence\Eloquent\Models;

use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;

final class TaskModel extends Model
{
    use HasUuids;

    protected $table = 'tasks';
    public $incrementing = false;
    protected $keyType = 'string';

    protected $fillable = [
        'id',
        'title',
        'priority',
        'is_completed',
    ];

    protected $casts = [
        'is_completed' => 'boolean',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
    ];
}
Enter fullscreen mode Exit fullscreen mode

2. Read Model (Optimized for Queries)

<?php

namespace App\Infrastructure\Persistence\ReadModel\Eloquent;

use Illuminate\Database\Eloquent\Model;

final class TaskViewModel extends Model
{
    protected $table = 'task_views';
    public $incrementing = false;
    protected $keyType = 'string';

    protected $fillable = [
        'id',
        'title',
        'priority',
        'is_completed',
    ];

    // Can have additional indexed fields,
    // computed columns, or denormalized data
}
Enter fullscreen mode Exit fullscreen mode

3. Repository Implementation

<?php

namespace App\Infrastructure\Persistence\Eloquent\Repositories;

use App\Domain\Task\Entities\Task;
use App\Domain\Task\Entities\TaskId;
use App\Domain\Task\Repositories\TaskRepository;
use App\Infrastructure\Mappers\TaskMapper;
use App\Infrastructure\Persistence\Eloquent\Models\TaskModel;

final class EloquentTaskRepository implements TaskRepository
{
    public function __construct(
        private readonly TaskMapper $mapper
    ) {
    }

    public function save(Task $task): void
    {
        $data = $this->mapper->toEloquent($task);

        TaskModel::updateOrCreate(
            ['id' => $data['id']],
            $data
        );
    }

    public function findById(TaskId $id): ?Task
    {
        $model = TaskModel::find($id->toString());

        if ($model === null) {
            return null;
        }

        return $this->mapper->toDomain($model);
    }

    public function delete(TaskId $id): void
    {
        TaskModel::where('id', $id->toString())->delete();
    }

    public function nextIdentity(): TaskId
    {
        return TaskId::generate();
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Mapper: Eloquent ↔ Domain

<?php

namespace App\Infrastructure\Mappers;

use App\Domain\Task\Entities\Task;
use App\Domain\Task\Entities\TaskId;
use App\Domain\Task\ValueObjects\Priority;
use App\Domain\Task\ValueObjects\Title;
use App\Infrastructure\Persistence\Eloquent\Models\TaskModel;
use DateTimeImmutable;

final class TaskMapper
{
    public function toEloquent(Task $task): array
    {
        return [
            'id' => $task->id()->toString(),
            'title' => $task->title()->toString(),
            'priority' => $task->priority()->toString(),
            'is_completed' => $task->isCompleted(),
            'created_at' => $task->createdAt(),
            'updated_at' => $task->updatedAt(),
        ];
    }

    public function toDomain(TaskModel $model): Task
    {
        return Task::reconstitute(
            TaskId::fromString($model->id),
            Title::fromString($model->title),
            Priority::fromString($model->priority),
            $model->is_completed,
            DateTimeImmutable::createFromMutable($model->created_at),
            DateTimeImmutable::createFromMutable($model->updated_at)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Interfaces Layer - User-Facing Endpoints

HTTP Controller

Controllers are thin - they just dispatch commands/queries:

<?php

namespace App\Interfaces\Http\Controllers;

use App\Application\Shared\Bus\CommandBus;
use App\Application\Shared\Bus\QueryBus;
use App\Application\Task\Commands\CreateTask;
use App\Application\Task\Queries\GetTaskById;
use App\Application\Task\Queries\ListTasks;
use App\Domain\Task\Repositories\TaskRepository;
use App\Interfaces\Http\Requests\Task\CreateTaskRequest;
use App\Interfaces\Http\Requests\Task\FilterTasksRequest;
use App\Interfaces\Http\Resources\Task\TaskCollection;
use App\Interfaces\Http\Resources\Task\TaskResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;

final class TaskController
{
    public function __construct(
        private readonly CommandBus $commandBus,
        private readonly QueryBus $queryBus,
        private readonly TaskRepository $taskRepository
    ) {
    }

    public function index(FilterTasksRequest $request): TaskCollection
    {
        $query = new ListTasks(
            priority: $request->validated('priority'),
            isCompleted: $request->validated('is_completed'),
            page: (int) $request->validated('page', 1),
            perPage: (int) $request->validated('per_page', 15)
        );

        $tasks = $this->queryBus->ask($query);

        return new TaskCollection($tasks);
    }

    public function store(CreateTaskRequest $request): JsonResponse
    {
        $taskId = $this->taskRepository->nextIdentity();

        $command = new CreateTask(
            id: $taskId->toString(),
            title: $request->validated('title'),
            priority: $request->validated('priority')
        );

        $this->commandBus->dispatch($command);

        return response()->json([
            'message' => 'Task created successfully',
            'id' => $taskId->toString(),
        ], Response::HTTP_CREATED);
    }

    public function show(string $id): TaskResource
    {
        $query = new GetTaskById($id);
        $task = $this->queryBus->ask($query);

        if ($task === null) {
            abort(Response::HTTP_NOT_FOUND, 'Task not found');
        }

        return new TaskResource($task);
    }
}
Enter fullscreen mode Exit fullscreen mode

Wiring Everything Together

1. Service Providers

DomainServiceProvider - Binds repositories:

<?php

namespace App\Infrastructure\Providers;

use App\Domain\Task\Repositories\TaskRepository;
use App\Infrastructure\Persistence\Eloquent\Repositories\EloquentTaskRepository;
use Illuminate\Support\ServiceProvider;

final class DomainServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(TaskRepository::class, function ($app) {
            return new EloquentTaskRepository(
                $app->make(\App\Infrastructure\Mappers\TaskMapper::class)
            );
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

CqrsServiceProvider - Registers command/query handlers:

<?php

namespace App\Infrastructure\Providers;

use App\Application\Shared\Bus\CommandBus;
use App\Application\Shared\Bus\QueryBus;
use App\Application\Shared\Bus\SimpleCommandBus;
use App\Application\Shared\Bus\SimpleQueryBus;
use App\Application\Task\CommandHandlers\CreateTaskHandler;
use App\Application\Task\Commands\CreateTask;
use App\Application\Task\Queries\ListTasks;
use App\Application\Task\QueryHandlers\ListTasksHandler;
use Illuminate\Support\ServiceProvider;

final class CqrsServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Register CommandBus
        $this->app->singleton(CommandBus::class, function ($app) {
            $bus = new SimpleCommandBus($app);

            $bus->register(CreateTask::class, CreateTaskHandler::class);
            // Register more commands...

            return $bus;
        });

        // Register QueryBus
        $this->app->singleton(QueryBus::class, function ($app) {
            $bus = new SimpleQueryBus($app);

            $bus->register(ListTasks::class, ListTasksHandler::class);
            // Register more queries...

            return $bus;
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Register Providers in bootstrap/app.php

<?php

use Illuminate\Foundation\Application;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
    )
    ->withProviders([
        \App\Infrastructure\Providers\DomainServiceProvider::class,
        \App\Infrastructure\Providers\CqrsServiceProvider::class,
        \App\Infrastructure\Providers\EventServiceProvider::class,
    ])
    ->create();
Enter fullscreen mode Exit fullscreen mode

3. Database Migrations

Write Model Table:

Schema::create('tasks', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('title');
    $table->enum('priority', ['low', 'medium', 'high']);
    $table->boolean('is_completed')->default(false);
    $table->timestamps();

    $table->index(['priority', 'is_completed']);
});
Enter fullscreen mode Exit fullscreen mode

Read Model Table:

Schema::create('task_views', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('title');
    $table->enum('priority', ['low', 'medium', 'high']);
    $table->boolean('is_completed')->default(false);
    $table->timestamps();

    // Optimized indexes for read operations
    $table->index('priority');
    $table->index('is_completed');
    $table->index(['priority', 'is_completed']);
});
Enter fullscreen mode Exit fullscreen mode

4. API Routes

<?php

use App\Interfaces\Http\Controllers\TaskController;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')->group(function () {
    Route::apiResource('tasks', TaskController::class);
});
Enter fullscreen mode Exit fullscreen mode

Testing the Application

1. Domain Tests (Unit)

Test business logic in isolation:

<?php

namespace Tests\Unit\Domain;

use App\Domain\Task\Entities\Task;
use App\Domain\Task\Entities\TaskId;
use App\Domain\Task\ValueObjects\Priority;
use App\Domain\Task\ValueObjects\Title;
use PHPUnit\Framework\TestCase;

final class TaskTest extends TestCase
{
    public function test_can_create_task(): void
    {
        $task = Task::create(
            TaskId::generate(),
            Title::fromString('Buy groceries'),
            Priority::high()
        );

        $this->assertFalse($task->isCompleted());
        $this->assertEquals('Buy groceries', $task->title()->toString());
    }

    public function test_can_mark_task_as_completed(): void
    {
        $task = Task::create(
            TaskId::generate(),
            Title::fromString('Task'),
            Priority::low()
        );

        $task->markAsCompleted();

        $this->assertTrue($task->isCompleted());
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Handler Tests (Unit)

<?php

namespace Tests\Unit\Application;

use App\Application\Task\CommandHandlers\CreateTaskHandler;
use App\Application\Task\Commands\CreateTask;
use App\Domain\Task\Repositories\TaskRepository;
use Illuminate\Contracts\Events\Dispatcher;
use PHPUnit\Framework\TestCase;

final class CreateTaskHandlerTest extends TestCase
{
    public function test_creates_task_successfully(): void
    {
        $repository = $this->createMock(TaskRepository::class);
        $dispatcher = $this->createMock(Dispatcher::class);

        $repository->expects($this->once())
            ->method('save');

        $handler = new CreateTaskHandler($repository, $dispatcher);

        $command = new CreateTask(
            id: '123e4567-e89b-12d3-a456-426614174000',
            title: 'Test Task',
            priority: 'high'
        );

        $handler->handle($command);
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Feature Tests (HTTP)

<?php

namespace Tests\Feature\Http;

use Tests\TestCase;

final class TaskControllerTest extends TestCase
{
    public function test_can_create_task_via_api(): void
    {
        $response = $this->postJson('/api/v1/tasks', [
            'title' => 'New Task',
            'priority' => 'high',
        ]);

        $response->assertStatus(201)
            ->assertJsonStructure(['message', 'id']);

        $this->assertDatabaseHas('tasks', [
            'title' => 'New Task',
            'priority' => 'high',
        ]);

        $this->assertDatabaseHas('task_views', [
            'title' => 'New Task',
            'priority' => 'high',
        ]);
    }

    public function test_can_list_tasks_with_filters(): void
    {
        $response = $this->getJson('/api/v1/tasks?priority=high');

        $response->assertStatus(200)
            ->assertJsonStructure([
                'data' => [
                    '*' => ['id', 'title', 'priority', 'is_completed']
                ],
                'meta' => ['total', 'current_page', 'per_page']
            ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Benefits and Trade-offs

✅ Benefits

  1. Scalability

    • Read and write models can scale independently
    • Optimize each for its specific use case
  2. Maintainability

    • Clear separation of concerns
    • Business logic isolated in Domain layer
    • Easy to locate and modify code
  3. Testability

    • Domain logic testable without database
    • Mock-friendly architecture
    • Fast unit tests
  4. Flexibility

    • Easy to add new queries without touching write side
    • Can add read models for different use cases
    • Technology-agnostic domain layer
  5. Performance

    • Optimized read models with custom indexes
    • Can denormalize data for faster queries
    • No complex JOINs in read operations

⚠️ Trade-offs

  1. Complexity

    • More files and layers than traditional MVC
    • Learning curve for team members
    • Requires discipline to maintain
  2. Eventual Consistency

    • Read model might be slightly behind write model
    • Need to handle this in UI/UX
  3. Duplication

    • Data stored in both write and read models
    • More migrations to manage
  4. Overhead

    • Might be overkill for small, simple applications
    • Best suited for complex domains

🎯 When to Use This Architecture

Good fit:

  • Complex business domains
  • Applications requiring scalability
  • Multiple read use cases
  • Long-term, enterprise projects
  • Large teams

Probably overkill:

  • Simple CRUD applications
  • MVPs or prototypes
  • Small personal projects
  • Short-term applications

Running the Application

Installation

# Install dependencies
composer require ramsey/uuid

# Run migrations
php artisan migrate

# Seed sample data
php artisan db:seed --class=TaskSeeder
Enter fullscreen mode Exit fullscreen mode

Artisan Commands

# Rebuild read model from write model
php artisan task:rebuild-read-model

# Sync tasks from external source
php artisan tasks:sync
Enter fullscreen mode Exit fullscreen mode

API Examples

# Create a task
curl -X POST http://localhost:8000/api/v1/tasks \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Implement DDD architecture",
    "priority": "high"
  }'

# List tasks with filters
curl http://localhost:8000/api/v1/tasks?priority=high&is_completed=false

# Get a specific task
curl http://localhost:8000/api/v1/tasks/{task-id}

# Update a task
curl -X PUT http://localhost:8000/api/v1/tasks/{task-id} \
  -H "Content-Type: application/json" \
  -d '{"title": "Updated title"}'
Enter fullscreen mode Exit fullscreen mode

Advanced Topics

Event Sourcing (Optional)

For complete auditability, you can store all changes as events:

// Store every state change
class TaskEventStore
{
    public function store(DomainEvent $event): void
    {
        DB::table('event_store')->insert([
            'aggregate_id' => $event->aggregateId(),
            'event_type' => get_class($event),
            'payload' => json_encode($event->toArray()),
            'occurred_at' => $event->occurredAt(),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Outbox Pattern

Guarantee event delivery with transactional outbox:

// In transaction: save entity + event
DB::transaction(function () use ($task, $event) {
    $this->repository->save($task);

    DB::table('outbox')->insert([
        'event_type' => get_class($event),
        'payload' => json_encode($event),
        'published' => false,
    ]);
});

// Later: publish events asynchronously
$events = DB::table('outbox')->where('published', false)->get();
foreach ($events as $event) {
    event(unserialize($event->payload));
    DB::table('outbox')->where('id', $event->id)->update(['published' => true]);
}
Enter fullscreen mode Exit fullscreen mode

Multiple Read Models

Create specialized views for different queries:

// Dashboard statistics read model
class TaskStatisticsView extends Model
{
    protected $table = 'task_statistics_views';
    // Pre-aggregated statistics
}

// User-specific read model
class UserTasksView extends Model
{
    protected $table = 'user_tasks_views';
    // Denormalized with user info
}
Enter fullscreen mode Exit fullscreen mode

Complete Architecture Flow Diagram

Here's how everything works together from an HTTP request to response:

┌─────────────────────────────────────────────────────────────────────┐
│                        WRITE OPERATION (Command)                     │
└─────────────────────────────────────────────────────────────────────┘

POST /api/v1/tasks
{ "title": "Deploy", "priority": "high" }
        ↓
┌───────────────────┐
│  TaskController   │  1. Validates request (FormRequest)
│  (Interface)      │  2. Generates TaskId
└─────────┬─────────┘  3. Creates CreateTask command
          ↓
┌───────────────────┐
│   CommandBus      │  4. Routes command to handler
│  (Application)    │     Using registered mappings
└─────────┬─────────┘
          ↓
┌───────────────────┐
│ CreateTaskHandler │  5. Converts strings → Value Objects
│  (Application)    │     TaskId, Title, Priority
└─────────┬─────────┘  6. Calls Task::create()
          ↓
┌───────────────────┐
│   Task Entity     │  7. Enforces business rules
│   (Domain)        │     - Tasks start incomplete
└─────────┬─────────┘     - Validates invariants
          ↓
┌───────────────────┐
│  TaskRepository   │  8. Converts Task → array
│  (Infrastructure) │  9. Saves to tasks table (Write Model)
└─────────┬─────────┘
          ↓
┌───────────────────┐
│  Event Dispatcher │  10. Emits TaskWasCreated event
│  (Infrastructure) │
└─────────┬─────────┘
          ↓
┌───────────────────┐
│  TaskProjector    │  11. Listens to event
│  (Application)    │  12. Updates task_views table (Read Model)
└───────────────────┘

Result: Both tables synchronized
- tasks table (source of truth) ✅
- task_views table (optimized for queries) ✅

─────────────────────────────────────────────────────────────────────

┌─────────────────────────────────────────────────────────────────────┐
│                        READ OPERATION (Query)                        │
└─────────────────────────────────────────────────────────────────────┘

GET /api/v1/tasks?priority=high&page=1
        ↓
┌───────────────────┐
│  TaskController   │  1. Validates query params
│  (Interface)      │  2. Creates ListTasks query
└─────────┬─────────┘
          ↓
┌───────────────────┐
│    QueryBus       │  3. Routes query to handler
│  (Application)    │     Using registered mappings
└─────────┬─────────┘
          ↓
┌───────────────────┐
│ ListTasksHandler  │  4. Queries TaskViewModel (Read Model)
│  (Application)    │  5. Applies filters (priority, completed)
└─────────┬─────────┘  6. Returns paginated results
          ↓
┌───────────────────┐
│  TaskViewModel    │  7. Reads from task_views table
│  (Infrastructure) │     Uses optimized indexes
└─────────┬─────────┘     Returns plain Eloquent models
          ↓
┌───────────────────┐
│  TaskCollection   │  8. Formats response as JSON
│  (Interface)      │     With data + metadata
└───────────────────┘

Result: Fast, optimized query
- No domain entity conversion needed
- Direct read from indexed table
- Pagination handled efficiently
Enter fullscreen mode Exit fullscreen mode

Key Points:

  1. Commands go through: Controller → CommandBus → Handler → Domain → Repository → Database
  2. Queries go through: Controller → QueryBus → Handler → ReadModel → Database
  3. Events synchronize: Write Model → Event → Projector → Read Model
  4. Write and Read paths are completely separate

Conclusion

Implementing DDD and CQRS in Laravel provides a robust foundation for building scalable, maintainable applications. While it introduces complexity, the benefits in terms of code organization, testability, and scalability are substantial for medium to large applications.

Key Takeaways

  1. Separate concerns - Each layer has a clear responsibility

    • Domain: Business rules (Task, Title, Priority)
    • Application: Use cases (CreateTaskHandler, ListTasksHandler)
    • Infrastructure: Technical details (Eloquent, Events)
    • Interfaces: User interaction (Controllers, API)
  2. Protect business logic - Keep it in the Domain layer

    • Value Objects validate input (Title, Priority)
    • Entities enforce invariants (Task)
    • No Eloquent or Laravel code in Domain
  3. Optimize independently - Write and read models serve different needs

    • Write model: Normalized, consistent (tasks table)
    • Read model: Denormalized, fast (task_views table)
    • Each can be scaled separately
  4. Event-driven sync - Keep read models updated automatically

    • Commands emit events after saving
    • Projectors listen and update read models
    • Can rebuild read model from write model if needed
  5. Test at all levels - From domain to HTTP

    • Unit tests: Domain entities and handlers (no database)
    • Integration tests: Repositories (with database)
    • Feature tests: Full HTTP flow

Next Steps

  • Add more aggregates (User, Project, etc.)
  • Implement event sourcing for complete audit trail
  • Add integration with message queues (RabbitMQ, Redis)
  • Create specialized read models for analytics
  • Implement CQRS middleware for logging, validation, etc.

Resources

Top comments (0)