DEV Community

A0mineTV
A0mineTV

Posted on

Building a Scalable Laravel Application with Domain-Driven Design (DDD)

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 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.) 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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; } } 
Enter fullscreen mode Exit fullscreen mode

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; } } 
Enter fullscreen mode Exit fullscreen mode

Why this matters:

  • Business rules are explicit (markAsCompleted checks 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; } 
Enter fullscreen mode Exit fullscreen mode

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); } } 
Enter fullscreen mode Exit fullscreen mode

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 ) {} } 
Enter fullscreen mode Exit fullscreen mode
<?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, ]; } } 
Enter fullscreen mode Exit fullscreen mode

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', ]; } 
Enter fullscreen mode Exit fullscreen mode

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 ); } } 
Enter fullscreen mode Exit fullscreen mode

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 ); } } 
Enter fullscreen mode Exit fullscreen mode

Don't forget to register it in bootstrap/providers.php:

return [ App\Providers\AppServiceProvider::class, App\Infrastructure\Providers\DomainServiceProvider::class, ]; 
Enter fullscreen mode Exit fullscreen mode

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'])], ]; } } 
Enter fullscreen mode Exit fullscreen mode

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); } } } 
Enter fullscreen mode Exit fullscreen mode

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'); }); 
Enter fullscreen mode Exit fullscreen mode

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(); }); 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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(); } 
Enter fullscreen mode Exit fullscreen mode

2. Business Rules Centralized

All validation logic is in one place:

  • Title validation → Title Value Object
  • Priority rules → Priority Value Object
  • Task lifecycle → Task Entity

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 
Enter fullscreen mode Exit fullscreen mode

✅ 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! } } 
Enter fullscreen mode Exit fullscreen mode

✅ 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(); } } 
Enter fullscreen mode Exit fullscreen mode

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! { // ... } 
Enter fullscreen mode Exit fullscreen mode

✅ Do: Keep Domain pure

namespace App\Domain\Task\Entities; // Pure PHP class, no framework dependencies ✅ class Task { // ... } 
Enter fullscreen mode Exit fullscreen mode

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'); } // ... } } 
Enter fullscreen mode Exit fullscreen mode

✅ 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); } } 
Enter fullscreen mode Exit fullscreen mode

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(); } } 
Enter fullscreen mode Exit fullscreen mode

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); } } 
Enter fullscreen mode Exit fullscreen mode

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())); } } 
Enter fullscreen mode Exit fullscreen mode

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' ]); } } 
Enter fullscreen mode Exit fullscreen mode

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:

  1. Aggregates: Group multiple entities under one root
  2. Specifications: Complex query logic in the domain
  3. CQRS: Separate read and write models
  4. Event Sourcing: Store events instead of state
  5. 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:

  1. Separate business logic from framework - Your domain should be pure PHP
  2. Use Value Objects extensively - They make invalid states unrepresentable
  3. Keep controllers thin - They should only validate and call use cases
  4. Test domain logic without database - It's faster and more reliable
  5. 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)