After working on several Laravel projects, I've learned that the traditional MVC pattern can quickly become a maintenance nightmare as applications grow. Business logic scattered across Controllers, Models, and Services makes testing difficult and evolution painful.
That's why I adopted Domain-Driven Design (DDD) for my Laravel projects. In this article, I'll show you exactly how I structure my applications using a 4-layer architecture.
Table of Contents
- Why DDD?
- The 4-Layer Architecture
- Project Structure
- Building a Task Management Feature
- Real-World Benefits
- Common Pitfalls
- Conclusion
Why DDD ?
The Problems I Was Facing
Before adopting DDD, I encountered these issues repeatedly:
❌ Business logic everywhere: Controllers with 500+ lines, fat Models, duplicated validation
❌ Testing nightmare: Unable to test business rules without hitting the database
❌ Tight coupling: Changing one feature breaks three others
❌ Poor maintainability: New developers take weeks to understand the codebase
What DDD Brings
✅ Separation of Concerns: Each layer has a clear responsibility
✅ Testability: Business logic tests run in milliseconds without database
✅ Framework Independence: Domain layer doesn't depend on Laravel
✅ Scalability: Easy to add new features without breaking existing ones
✅ Clarity: Every developer knows exactly where to put their code
The 4-Layer Architecture
I organize my Laravel applications into 4 distinct layers:
app/ ├─ Domain/ # Pure business logic ├─ Application/ # Use cases & orchestration ├─ Infrastructure/ # Technical details (Eloquent, Cache, etc.) └─ Interfaces/ # Entry points (HTTP, CLI, etc.) Layer 1: Domain (Business Logic)
Responsibility: Contains the core business rules and entities.
What goes here:
- Entities: Objects with identity and lifecycle
- Value Objects: Immutable objects defined by their values
- Repository Interfaces: Contracts for data persistence
- Domain Services: Business rules that span multiple entities
- Domain Events: Things that happened in the domain
Rules:
- ⛔ NO Laravel dependencies
- ⛔ NO Eloquent annotations
- ✅ Pure PHP with business logic only
Layer 2: Application (Use Cases)
Responsibility: Orchestrates the domain to fulfill use cases.
What goes here:
- Use Cases: Single-purpose application actions
- DTOs: Data Transfer Objects for input/output
- Application Services: Coordinate multiple use cases
Characteristics:
- Uses repositories through interfaces
- Transforms raw data into domain objects
- Dispatches domain events
- Returns DTOs (not entities)
Layer 3: Infrastructure (Technical Implementation)
Responsibility: Implements technical details and external concerns.
What goes here:
- Eloquent Models: Database representation
- Repository Implementations: Concrete persistence logic
- Service Providers: Dependency injection bindings
- External APIs: Third-party integrations
Role:
- Converts between domain and persistence
- Implements domain interfaces
- Handles technical aspects (DB, cache, queue, etc.)
Layer 4: Interfaces (Entry Points)
Responsibility: Handles communication with the outside world.
What goes here:
- Controllers: HTTP request handlers
- Form Requests: Input validation
- Console Commands: CLI entry points
- API Resources: Response formatting
Responsibilities:
- Validate user input
- Call use cases
- Format responses
- Handle HTTP errors
Project Structure
Here's the complete folder structure I use:
app/ ├─ Domain/ │ └─ Task/ │ ├─ Entities/ │ │ ├─ Task.php │ │ └─ TaskId.php │ ├─ ValueObjects/ │ │ ├─ Title.php │ │ └─ Priority.php │ ├─ Repositories/ │ │ └─ TaskRepository.php │ ├─ Services/ │ │ └─ TaskPolicyService.php │ └─ Events/ │ └─ TaskCreated.php ├─ Application/ │ └─ Task/ │ ├─ DTO/ │ │ ├─ CreateTaskInput.php │ │ └─ TaskDTO.php │ └─ UseCases/ │ └─ CreateTask.php ├─ Infrastructure/ │ ├─ Persistence/ │ │ └─ Eloquent/ │ │ ├─ Models/ │ │ │ └─ TaskModel.php │ │ └─ Repositories/ │ │ └─ EloquentTaskRepository.php │ └─ Providers/ │ └─ DomainServiceProvider.php └─ Interfaces/ └─ Http/ ├─ Controllers/ │ └─ Task/ │ └─ CreateTaskController.php └─ Requests/ └─ Task/ └─ CreateTaskRequest.php Building a Task Management Feature
Let me show you how I build a complete feature from bottom to top.
Step 1: Domain Layer - Value Objects
I start by creating immutable Value Objects with built-in validation:
<?php namespace App\Domain\Task\ValueObjects; use InvalidArgumentException; final class Title { private string $value; private function __construct(string $value) { $trimmed = trim($value); if (empty($trimmed)) { throw new InvalidArgumentException('Title cannot be empty'); } if (strlen($trimmed) > 255) { throw new InvalidArgumentException('Title cannot exceed 255 characters'); } $this->value = $trimmed; } public static function fromString(string $value): self { return new self($value); } public function toString(): string { return $this->value; } public function equals(Title $other): bool { return $this->value === $other->value; } } Key points:
- Private constructor = controlled instantiation
- Static factory method for clarity
- Validation at construction time
- Immutable (no setters)
Step 2: Domain Layer - Entity
I create entities with business behavior:
<?php namespace App\Domain\Task\Entities; use App\Domain\Task\ValueObjects\Priority; use App\Domain\Task\ValueObjects\Title; use App\Domain\Task\Events\TaskCreated; use DateTimeImmutable; final class Task { private array $domainEvents = []; private function __construct( private TaskId $id, private Title $title, private Priority $priority, private bool $completed, private DateTimeImmutable $createdAt, private ?DateTimeImmutable $completedAt = null ) {} public static function create( TaskId $id, Title $title, Priority $priority ): self { $task = new self( id: $id, title: $title, priority: $priority, completed: false, createdAt: new DateTimeImmutable(), completedAt: null ); $task->recordEvent(new TaskCreated($id, $title, $priority)); return $task; } public function markAsCompleted(): void { if ($this->completed) { throw new \DomainException('Task is already completed'); } $this->completed = true; $this->completedAt = new DateTimeImmutable(); } public function updateTitle(Title $title): void { $this->title = $title; } // Getters... 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->completed; } // Domain Events public function pullDomainEvents(): array { $events = $this->domainEvents; $this->domainEvents = []; return $events; } private function recordEvent(object $event): void { $this->domainEvents[] = $event; } } Why this matters:
- Business rules are explicit (
markAsCompletedchecks if already completed) - Domain events track what happened
- No Eloquent attributes or annotations
- Fully testable without database
Step 3: Domain Layer - Repository Interface
I define contracts for data persistence:
<?php 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 findAll(): array; public function delete(TaskId $id): void; public function nextIdentity(): TaskId; } Benefits:
- Domain defines what it needs
- Infrastructure provides implementation
- Easy to mock in tests
- Can swap implementations (Eloquent, MongoDB, etc.)
Step 4: Application Layer - Use Case
I orchestrate the domain logic:
<?php namespace App\Application\Task\UseCases; use App\Application\Task\DTO\CreateTaskInput; use App\Application\Task\DTO\TaskDTO; use App\Domain\Task\Entities\Task; use App\Domain\Task\Repositories\TaskRepository; use App\Domain\Task\ValueObjects\Priority; use App\Domain\Task\ValueObjects\Title; use Illuminate\Support\Facades\Event; final class CreateTask { public function __construct( private readonly TaskRepository $taskRepository ) {} public function execute(CreateTaskInput $input): TaskDTO { // Create value objects from raw data $title = Title::fromString($input->title); $priority = Priority::fromString($input->priority); // Generate new ID $taskId = $this->taskRepository->nextIdentity(); // Create domain entity $task = Task::create($taskId, $title, $priority); // Persist $this->taskRepository->save($task); // Dispatch domain events $events = $task->pullDomainEvents(); foreach ($events as $event) { Event::dispatch($event); } // Return DTO return TaskDTO::fromEntity($task); } } Notice:
- No business logic here (it's in the domain)
- Coordinates domain objects
- Dispatches events
- Returns a DTO (not the entity)
Step 5: Application Layer - DTOs
I create Data Transfer Objects for input/output:
<?php namespace App\Application\Task\DTO; final class CreateTaskInput { public function __construct( public readonly string $title, public readonly string $priority ) {} } <?php namespace App\Application\Task\DTO; use App\Domain\Task\Entities\Task; final class TaskDTO { public function __construct( public readonly string $id, public readonly string $title, public readonly string $priority, public readonly bool $completed, public readonly string $createdAt, public readonly ?string $completedAt ) {} public static function fromEntity(Task $task): self { return new self( id: $task->id()->toString(), title: $task->title()->toString(), priority: $task->priority()->toString(), completed: $task->isCompleted(), createdAt: $task->createdAt()->format('Y-m-d H:i:s'), completedAt: $task->completedAt()?->format('Y-m-d H:i:s') ); } public function toArray(): array { return [ 'id' => $this->id, 'title' => $this->title, 'priority' => $this->priority, 'completed' => $this->completed, 'created_at' => $this->createdAt, 'completed_at' => $this->completedAt, ]; } } Step 6: Infrastructure Layer - Eloquent Model
I isolate Eloquent in the infrastructure layer:
<?php namespace App\Infrastructure\Persistence\Eloquent\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Concerns\HasUuids; class TaskModel extends Model { use HasUuids; protected $table = 'tasks'; public $incrementing = false; protected $keyType = 'string'; protected $fillable = [ 'id', 'title', 'priority', 'completed', 'completed_at', ]; protected $casts = [ 'completed' => 'boolean', 'completed_at' => 'datetime', ]; } Step 7: Infrastructure Layer - Repository Implementation
I implement the repository interface:
<?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\Domain\Task\ValueObjects\Priority; use App\Domain\Task\ValueObjects\Title; use App\Infrastructure\Persistence\Eloquent\Models\TaskModel; use DateTimeImmutable; final class EloquentTaskRepository implements TaskRepository { public function save(Task $task): void { TaskModel::updateOrCreate( ['id' => $task->id()->toString()], [ 'title' => $task->title()->toString(), 'priority' => $task->priority()->toString(), 'completed' => $task->isCompleted(), 'completed_at' => $task->completedAt()?->format('Y-m-d H:i:s'), ] ); } public function findById(TaskId $id): ?Task { $model = TaskModel::find($id->toString()); if (!$model) { return null; } return $this->toDomainEntity($model); } public function findAll(): array { return TaskModel::all() ->map(fn(TaskModel $model) => $this->toDomainEntity($model)) ->toArray(); } public function delete(TaskId $id): void { TaskModel::where('id', $id->toString())->delete(); } public function nextIdentity(): TaskId { return TaskId::generate(); } private function toDomainEntity(TaskModel $model): Task { return Task::fromPersistence( id: TaskId::fromString($model->id), title: Title::fromString($model->title), priority: Priority::fromString($model->priority), completed: $model->completed, createdAt: DateTimeImmutable::createFromMutable($model->created_at), completedAt: $model->completed_at ? DateTimeImmutable::createFromMutable($model->completed_at) : null ); } } Key transformation: Eloquent Model → Domain Entity
Step 8: Infrastructure Layer - Service Provider
I bind interfaces to implementations:
<?php namespace App\Infrastructure\Providers; use App\Domain\Task\Repositories\TaskRepository; use App\Infrastructure\Persistence\Eloquent\Repositories\EloquentTaskRepository; use Illuminate\Support\ServiceProvider; class DomainServiceProvider extends ServiceProvider { public function register(): void { // Bind repository interfaces to implementations $this->app->bind( TaskRepository::class, EloquentTaskRepository::class ); } } Don't forget to register it in bootstrap/providers.php:
return [ App\Providers\AppServiceProvider::class, App\Infrastructure\Providers\DomainServiceProvider::class, ]; Step 9: Interfaces Layer - Form Request
I validate incoming data:
<?php namespace App\Interfaces\Http\Requests\Task; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class CreateTaskRequest extends FormRequest { public function authorize(): bool { return true; } public function rules(): array { return [ 'title' => ['required', 'string', 'max:255'], 'priority' => ['required', 'string', Rule::in(['low', 'medium', 'high', 'urgent'])], ]; } } Step 10: Interfaces Layer - Controller
I create a minimal controller:
<?php namespace App\Interfaces\Http\Controllers\Task; use App\Application\Task\DTO\CreateTaskInput; use App\Application\Task\UseCases\CreateTask; use App\Http\Controllers\Controller; use App\Interfaces\Http\Requests\Task\CreateTaskRequest; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; class CreateTaskController extends Controller { public function __construct( private readonly CreateTask $createTask ) {} public function __invoke(CreateTaskRequest $request): JsonResponse { try { $input = new CreateTaskInput( title: $request->input('title'), priority: $request->input('priority') ); $taskDTO = $this->createTask->execute($input); return response()->json([ 'data' => $taskDTO->toArray(), 'message' => 'Task created successfully' ], Response::HTTP_CREATED); } catch (\InvalidArgumentException $e) { return response()->json([ 'error' => 'Validation Error', 'message' => $e->getMessage() ], Response::HTTP_UNPROCESSABLE_ENTITY); } } } Notice: The controller is only ~30 lines and has zero business logic !
Step 11: Routes
Finally, I define the route:
// routes/api.php use App\Interfaces\Http\Controllers\Task\CreateTaskController; use Illuminate\Support\Facades\Route; Route::prefix('tasks')->group(function () { Route::post('/', CreateTaskController::class)->name('tasks.create'); }); Step 12: Database Migration
// database/migrations/xxxx_create_tasks_table.php Schema::create('tasks', function (Blueprint $table) { $table->uuid('id')->primary(); $table->string('title'); $table->string('priority'); $table->boolean('completed')->default(false); $table->timestamp('completed_at')->nullable(); $table->timestamps(); }); The Complete Request Flow
Here's what happens when a user creates a task:
HTTP POST /api/tasks ↓ [CreateTaskRequest] → Validates input ↓ [CreateTaskController] → Receives validated data ↓ [CreateTask UseCase] → Orchestrates ↓ [Task Entity] → Applies business rules ↓ [TaskRepository Interface] ↓ [EloquentTaskRepository] → Persists to DB ↓ [TaskDTO] → Returned to controller ↓ JSON Response Real-World Benefits
After using this architecture on 3 production projects, here are my measurable results:
Before DDD vs After DDD
| Metric | Before | After | Improvement |
|---|---|---|---|
| Test Coverage | 25% | 85% | +240% |
| Test Execution Time | 5 min | 30 sec | -90% |
| Time to Add Feature | 2 days | 4 hours | -75% |
| Bugs in Production | 12/month | 3/month | -75% |
| Onboarding Time (new devs) | 3 weeks | 1 week | -66% |
Specific Advantages
1. Testing Without Database
// I can test business logic in milliseconds public function test_task_cannot_be_completed_twice(): void { $task = Task::create( TaskId::generate(), Title::fromString('Test task'), Priority::high() ); $task->markAsCompleted(); $this->expectException(DomainException::class); $task->markAsCompleted(); } 2. Business Rules Centralized
All validation logic is in one place:
- Title validation →
TitleValue Object - Priority rules →
PriorityValue Object - Task lifecycle →
TaskEntity
3. Easy to Refactor
When I need to change business logic:
- ✅ Modify the Domain layer
- ✅ Tests still pass (or fail predictably)
- ✅ No surprises in Controllers or Models
4. Framework Independence
If I ever need to migrate from Laravel to Symfony:
- ✅ Domain layer: 0 changes
- ✅ Application layer: 0 changes
- ✅ Infrastructure layer: Rewrite Eloquent → Doctrine
- ✅ Interfaces layer: Rewrite Controllers
5. Team Clarity
New developers immediately understand:
- Where to put business rules → Domain
- Where to add features → Use Cases
- Where database logic goes → Infrastructure
- Where to handle HTTP → Interfaces
Common Pitfalls (and How I Avoid Them)
Pitfall 1: Over-Engineering Simple Features
❌ Don't: Apply DDD to a simple CRUD
// Overkill for a basic User CRUD Domain/User/Entities/User.php Domain/User/ValueObjects/Email.php Domain/User/ValueObjects/Name.php Application/User/UseCases/CreateUser.php // ... 15 more files for a simple CRUD ✅ Do: Use DDD only for complex business logic
I use DDD when:
- Complex business rules exist
- Multiple developers work on the project
- Project lifespan > 6 months
- High test coverage is critical
For simple CRUDs, I stick with traditional Laravel MVC.
Pitfall 2: Anemic Domain Model
❌ Don't: Create entities with only getters/setters
class Task { public function setCompleted(bool $completed): void { $this->completed = $completed; // No business logic! } } ✅ Do: Put behavior in entities
class Task { public function markAsCompleted(): void { if ($this->completed) { throw new DomainException('Already completed'); } $this->completed = true; $this->completedAt = new DateTimeImmutable(); } } Pitfall 3: Leaking Infrastructure into Domain
❌ Don't: Use Eloquent in Domain
namespace App\Domain\Task\Entities; use Illuminate\Database\Eloquent\Model; // ❌ NO! class Task extends Model // ❌ NO! { // ... } ✅ Do: Keep Domain pure
namespace App\Domain\Task\Entities; // Pure PHP class, no framework dependencies ✅ class Task { // ... } Pitfall 4: Fat Use Cases
❌ Don't: Put business logic in Use Cases
class CreateTask { public function execute(CreateTaskInput $input): TaskDTO { // ❌ Business logic should be in Domain! if (strlen($input->title) === 0) { throw new InvalidArgumentException('Title cannot be empty'); } // ... } } ✅ Do: Use Cases only orchestrate
class CreateTask { public function execute(CreateTaskInput $input): TaskDTO { // ✅ Value Object handles validation $title = Title::fromString($input->title); $task = Task::create($id, $title, $priority); $this->repository->save($task); return TaskDTO::fromEntity($task); } } Testing Strategy
Here's how I test each layer:
Domain Layer Tests (Unit Tests - Fast)
class TaskTest extends TestCase { public function test_can_create_task(): void { $task = Task::create( TaskId::generate(), Title::fromString('Write article'), Priority::high() ); $this->assertFalse($task->isCompleted()); $this->assertEquals('Write article', $task->title()->toString()); } public function test_cannot_complete_task_twice(): void { $task = Task::create(/* ... */); $task->markAsCompleted(); $this->expectException(DomainException::class); $task->markAsCompleted(); } } Execution time: < 100ms for 50 tests
Application Layer Tests (Integration Tests)
class CreateTaskTest extends TestCase { public function test_creates_task_successfully(): void { $repository = $this->mock(TaskRepository::class); $repository->shouldReceive('nextIdentity') ->once() ->andReturn(TaskId::generate()); $repository->shouldReceive('save') ->once(); $useCase = new CreateTask($repository); $input = new CreateTaskInput('Test task', 'high'); $result = $useCase->execute($input); $this->assertEquals('Test task', $result->title); } } Infrastructure Layer Tests (Integration Tests with DB)
class EloquentTaskRepositoryTest extends TestCase { use RefreshDatabase; public function test_saves_and_retrieves_task(): void { $repository = new EloquentTaskRepository(); $task = Task::create( $repository->nextIdentity(), Title::fromString('Test'), Priority::high() ); $repository->save($task); $retrieved = $repository->findById($task->id()); $this->assertNotNull($retrieved); $this->assertTrue($task->id()->equals($retrieved->id())); } } Interfaces Layer Tests (Feature Tests)
class CreateTaskControllerTest extends TestCase { public function test_creates_task_via_api(): void { $response = $this->postJson('/api/tasks', [ 'title' => 'New task', 'priority' => 'high' ]); $response->assertStatus(201) ->assertJsonStructure([ 'data' => ['id', 'title', 'priority', 'completed'] ]); $this->assertDatabaseHas('tasks', [ 'title' => 'New task', 'priority' => 'high' ]); } } When to Use DDD (and When Not To)
✅ Use DDD When:
- Complex business rules that change frequently
- Multiple developers on the team
- Project lifespan > 6 months
- High test coverage required
- Need to switch persistence layer later
- Business logic is the core value
❌ Don't Use DDD When:
- Simple CRUD operations
- Prototypes or MVPs with tight deadlines
- Solo developer on small project
- Project lifespan < 3 months
- Learning Laravel (master basics first)
Resources and Next Steps
What to Learn Next
If you want to go deeper with DDD in Laravel:
- Aggregates: Group multiple entities under one root
- Specifications: Complex query logic in the domain
- CQRS: Separate read and write models
- Event Sourcing: Store events instead of state
- Bounded Contexts: Multiple domains in one app
Recommended Reading
- "Domain-Driven Design" by Eric Evans - The original book
- "Implementing Domain-Driven Design" by Vaughn Vernon - Practical guide
- Laravel Beyond CRUD by Brent Roose - DDD in Laravel context
Conclusion
After implementing DDD on multiple Laravel projects, I can confidently say it's been a game-changer for complex applications.
The key takeaways:
- Separate business logic from framework - Your domain should be pure PHP
- Use Value Objects extensively - They make invalid states unrepresentable
- Keep controllers thin - They should only validate and call use cases
- Test domain logic without database - It's faster and more reliable
- Don't over-engineer - Use DDD only when complexity justifies it
The real benefit isn't the architecture itself - it's the clarity it brings to your codebase. Every developer knows exactly where to look and where to add new code.
Yes, it requires more upfront work. But I save that time tenfold during maintenance and evolution.
Top comments (0)