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
- Understanding the Architecture
- Project Structure
- Domain Layer - The Heart of Your Business
- Application Layer - Orchestrating Use Cases
- Infrastructure Layer - Technical Implementation
- Interfaces Layer - User-Facing Endpoints
- Wiring Everything Together
- Testing the Application
- 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) │
└─────────────────────────────────────────┘
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
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;
}
}
How it works:
-
Creation:
Title::fromString("Buy groceries")
- Calls the private constructor
- Validates the input
- Returns a valid Title or throws an exception
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"
- 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");
- Comparison:
$title1 = Title::fromString("Buy groceries");
$title2 = Title::fromString("Buy groceries");
$title1->equals($title2); // Returns true - same value
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; }
}
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!
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
Why This Matters:
- Business logic is centralized - All task rules live in the Task entity
- Invariants are protected - Can't create a task that violates business rules
- Testable - No database or framework needed to test business logic
- Clear API - Methods express business intent (markAsCompleted, not setCompleted)
- 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;
}
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
) {
}
}
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
}
}
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"
}
Why This Pattern?
-
Separation of Concerns:
- Controller: HTTP handling
- Command: Data transfer
- Handler: Business orchestration
- Entity: Business rules
- Repository: Persistence
Testability:
// Easy to test without HTTP, database, or framework
$handler = new CreateTaskHandler($mockRepository, $mockDispatcher);
$handler->handle(new CreateTask("id", "title", "high"));
-
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
-
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
) {
}
}
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;
}
}
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
}
}
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?
- 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;
- 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
];
}
- 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
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();
Why This Pattern?
- Scalability: Read model can be on a different database server
- Performance: Optimized indexes for specific queries
- Flexibility: Add new read models without touching write side
- 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,
]);
}
}
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',
];
}
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
}
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();
}
}
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)
);
}
}
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);
}
}
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)
);
});
}
}
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;
});
}
}
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();
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']);
});
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']);
});
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);
});
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());
}
}
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);
}
}
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']
]);
}
}
Benefits and Trade-offs
✅ Benefits
-
Scalability
- Read and write models can scale independently
- Optimize each for its specific use case
-
Maintainability
- Clear separation of concerns
- Business logic isolated in Domain layer
- Easy to locate and modify code
-
Testability
- Domain logic testable without database
- Mock-friendly architecture
- Fast unit tests
-
Flexibility
- Easy to add new queries without touching write side
- Can add read models for different use cases
- Technology-agnostic domain layer
-
Performance
- Optimized read models with custom indexes
- Can denormalize data for faster queries
- No complex JOINs in read operations
⚠️ Trade-offs
-
Complexity
- More files and layers than traditional MVC
- Learning curve for team members
- Requires discipline to maintain
-
Eventual Consistency
- Read model might be slightly behind write model
- Need to handle this in UI/UX
-
Duplication
- Data stored in both write and read models
- More migrations to manage
-
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
Artisan Commands
# Rebuild read model from write model
php artisan task:rebuild-read-model
# Sync tasks from external source
php artisan tasks:sync
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"}'
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(),
]);
}
}
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]);
}
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
}
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
Key Points:
- Commands go through: Controller → CommandBus → Handler → Domain → Repository → Database
- Queries go through: Controller → QueryBus → Handler → ReadModel → Database
- Events synchronize: Write Model → Event → Projector → Read Model
- 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
-
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)
-
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
-
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
-
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
-
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.
Top comments (0)