🏗️ Step-by-step guide

Start a Laravel app
the DDD way

Most tutorials start with code. This guide starts with thinking — because DDD is a design philosophy before it's a folder structure. Follow these 10 steps in order and you'll build a production-ready DDD Laravel app from a blank screen.

1
Think in domains
2
Project setup
3
Shared Kernel
4
Folder structure
5
Domain layer
6
Application layer
7
Infrastructure
8
Presentation
9
Wire & test
10
Scale up
1
Before you write a single line of code
Think in domains first

The biggest mistake beginners make is opening their IDE immediately. DDD starts on paper — or a whiteboard. You need to understand your business before you structure your code.

What is your "domain"?

Your domain is the real-world problem your application solves. Before writing any PHP, answer these questions about your app:

📝 The 5 questions you must answer first
  • ?
    What does this app do? — Describe it in one sentence using business language, not technical terms. "Merchants place delivery orders, drivers fulfil them."
  • ?
    Who are the actors? — List every person or system that interacts: Admin, Merchant, Driver, Customer, Payment Gateway.
  • ?
    What are the core business concepts? — The nouns: Order, Delivery, Driver, Merchant, Payment, Route, Zone.
  • ?
    What are the business rules? — "A driver must be online to receive orders." "A merchant must pass KYC before placing orders." Write every rule down.
  • ?
    What are the business events? — Things that happen: Order Placed, Driver Assigned, Delivery Completed, Payment Settled.

Identify your Bounded Contexts (modules)

A Bounded Context is an independent part of the business with its own rules and language. Group your business concepts into natural clusters. Each cluster becomes a module (folder) in your code.

💡 How to spot Bounded Contexts

Ask yourself: "Could a separate small team own this area completely, without needing to know the details of other areas?" If yes — it's a Bounded Context. The word "Driver" might mean different things in Dispatch context (location, availability) vs Payment context (earnings, bank account). That ambiguity = separate contexts.

Example: Delivery app Bounded Contexts

🏪 Merchant Context

Everything about businesses that send packages. KYC verification, billing settings, business profiles. Rules: must verify KYC before ordering.

Merchant KycStatus BillingMode

🚗 Driver Context

Everything about couriers. GPS tracking, availability, performance. Rules: must be Online to receive dispatches, cannot go Online while Busy.

Driver DriverStatus DriverLocation

📦 Delivery Context

The actual delivery order lifecycle. Pickup, dropoff, route, status tracking. References Merchant and Driver by UUID only.

Order Delivery Route

💳 Payment Context

Money movement — merchant charges, driver payouts, refunds. Has its own concept of "transaction" — unrelated to other contexts.

Transaction Payout Wallet

Write your Ubiquitous Language

Ubiquitous Language means: everyone on the team uses the same words for the same things. Write a glossary. This directly becomes your class names.

// Your glossary becomes your code vocabulary
// If the business says "go online" → your method is goOnline(), not activate() or setAvailable()
// If the business says "KYC" → your class is KycStatus, not VerificationStatus
// If the business says "delivery fee" → your field is deliveryFee, not price or cost

// ✅ Good — matches business language exactly:
$driver->goOnline($location);
$merchant->approveKyc();
$order->assignDriver($driverUuid);

// ❌ Bad — technical names that don't match business language:
$driver->setStatus('active');
$merchant->updateVerification(true);
$order->linkEntity($driverUuid);
✅ Deliverable from Step 1

A list of your Bounded Contexts (modules), a glossary of business terms, a list of business rules, and a list of domain events. Written on paper — no code yet.

2
Environment Setup
Set up the Laravel project

A standard Laravel install with two structural changes: a custom source directory for your DDD modules, and autoloading configured to find your namespaces.

Install Laravel

# Create a new Laravel project
composer create-project laravel/laravel delivery-app
cd delivery-app

# Or with Laravel installer
laravel new delivery-app --git

Create the src/ directory structure

By default, Laravel uses app/ for everything. In DDD, your modules live in src/ — separate from Laravel's own framework code in app/.

# Create the top-level structure
mkdir -p src/Shared/Domain/ValueObjects
mkdir -p src/Shared/Domain/Events
mkdir -p src/Shared/Domain/Exceptions
mkdir -p src/Shared/Domain/Contracts
mkdir -p src/Shared/Application
mkdir -p src/Shared/Infrastructure/Bus
mkdir -p src/Shared/Infrastructure/Providers

Configure Composer autoloading

Open composer.json and add your src/ directory to the PSR-4 autoloader. This tells PHP where to find your namespace.

{
  "autoload": {
    "psr-4": {
      "App\\": "app/",
      "DeliveryApp\\": "src/"    ← add this line
    }
  }
}

# After editing composer.json, regenerate the autoloader
composer dump-autoloadcomposer.json

Keep app/ lean — what stays there

LocationWhat goes hereWhat does NOT go here
app/Laravel's bootstrap files, global exception handler, HTTP kernel, middleware, console commands (thin shells only)Business logic, Domain entities, Application services
src/All your DDD modules: Shared, Driver, Merchant, Delivery, PaymentLaravel framework files
database/Migrations, seeders, factoriesBusiness logic
tests/All tests — Unit (Domain), Feature (API)

Recommended packages to install now

# Authentication (for API tokens)
composer require laravel/sanctum

# Role/permission middleware (for admin/driver/merchant roles)
composer require spatie/laravel-permission

# Dev tools
composer require --dev pestphp/pest pestphp/pest-plugin-laravel
composer require --dev nunomaduro/larastan phpstan/phpstan

# Optional: Architecture testing (enforce DDD rules in CI)
composer require --dev pest-plugin/arch
⚠️ Do not install this

Don't install packages that mix business logic into Laravel (like "auto-generated CRUD" packages). They work against DDD — they couple business rules to the framework.

Configure strict types globally

Add declare(strict_types=1); at the top of every PHP file in src/. This prevents accidental type coercion and catches bugs at runtime.

<?php

declare(strict_types=1);   ← every file in src/ starts with this

namespace DeliveryApp\Driver\Domain\Entities;

final class Driver extends AggregateRoot { ... }
✅ End of Step 2

Laravel installed. src/ folder created. Composer autoloading configured for DeliveryApp\ namespace. Packages installed. You're ready to build.

3
Foundation
Build the Shared Kernel first

Before building any module, build the shared foundation every module depends on. This is the first real code you write — and it's the most important.

Why Shared comes first

Every Domain Entity will extend AggregateRoot. Every entity will use Uuid. Every event will extend DomainEvent. Every service will type-hint DomainEventBus. If you build a module first, you'll be inventing these ad-hoc — and doing it differently for each module. Build Shared first. Build it once. Build it correctly.

Build these files in this order

1
DomainEvent.php — the base of all events
Abstract class with $occurredAt, abstract eventName(), abstract toArray(). Every event in every module extends this.
abstract class DomainEvent
{
    public readonly DateTimeImmutable $occurredAt;

    public function __construct()
    {
        $this->occurredAt = new DateTimeImmutable();
    }

    abstract public function eventName(): string;
    abstract public function toArray(): array;
}src/Shared/Domain/Events/DomainEvent.php
2
DomainException.php — the base of all business errors
Empty abstract class extending RuntimeException. Used as a marker — caught by global handler → HTTP 422.
abstract class DomainException extends RuntimeException {}
src/Shared/Domain/Exceptions/DomainException.php
3
AggregateRoot.php — the event recorder
Abstract class with private $pendingEvents[], protected recordEvent(), public pullDomainEvents().
4
DomainEventBus.php — the event dispatch contract
Interface with dispatch(DomainEvent) and dispatchAll(iterable). Lives in Contracts/ because Infrastructure implements it.
5
Value Objects — Uuid, Money, Email, PhoneNumber, Coordinates, Address
Write only the ones your app actually needs. A blog needs Uuid and Email. A delivery app needs all six. Don't add Value Objects speculatively.
6
LaravelDomainEventBus.php — Infrastructure implementation
Implements DomainEventBus using Illuminate\Contracts\Events\Dispatcher. Forwards each event to Laravel's own event system.
7
SharedKernelServiceProvider.php — the wiring
Registers DomainEventBus::class → LaravelDomainEventBus::class as a singleton. Register this provider first in bootstrap/providers.php.
// bootstrap/providers.php
return [
    SharedKernelServiceProvider::class,  ← first!
    DriverServiceProvider::class,
    MerchantServiceProvider::class,
];bootstrap/providers.php

Register the global DomainException handler

// bootstrap/app.php (Laravel 11) or app/Exceptions/Handler.php (Laravel 10)
use DeliveryApp\Shared\Domain\Exceptions\DomainException;

$exceptions->render(function (DomainException $e) {
    return response()->json([
        'error'   => 'business_rule_violation',
        'message' => $e->getMessage(),
    ], 422);
});
// Write this once — every module's domain exceptions are caught automaticallybootstrap/app.php
✅ End of Step 3

Shared Kernel is complete. Every module you build from now on imports from here. You should have tests for every Value Object already — they're the most critical code in your system.

4
First Module
Create your first module's folder structure

Pick the most important bounded context in your app — the one everything else depends on — and scaffold its complete folder structure before writing a single class. We'll use a Driver module as the example.

Run these commands to scaffold one module

# Replace "Driver" with your module name (Merchant, Order, Payment, etc.)
MODULE=Driver

mkdir -p src/$MODULE/Presentation/Http/Controllers
mkdir -p src/$MODULE/Presentation/Http/Requests
mkdir -p src/$MODULE/Presentation/Http/Resources
mkdir -p src/$MODULE/Presentation/Routes

mkdir -p src/$MODULE/Application/Commands
mkdir -p src/$MODULE/Application/Queries
mkdir -p src/$MODULE/Application/Handlers
mkdir -p src/$MODULE/Application/Services
mkdir -p src/$MODULE/Application/DTOs
mkdir -p src/$MODULE/Application/Listeners

mkdir -p src/$MODULE/Domain/Entities
mkdir -p src/$MODULE/Domain/ValueObjects
mkdir -p src/$MODULE/Domain/Events
mkdir -p src/$MODULE/Domain/Exceptions
mkdir -p src/$MODULE/Domain/Repositories

mkdir -p src/$MODULE/Infrastructure/Persistence/Eloquent/Models
mkdir -p src/$MODULE/Infrastructure/Persistence/Eloquent/Repositories
mkdir -p src/$MODULE/Infrastructure/Providers
mkdir -p src/$MODULE/Infrastructure/Jobs

The resulting structure

src/Driver/ │ ├── Presentation/ ← HTTP only. No business logic here. │ ├── Http/Controllers/ │ │ └── DriverController.php │ ├── Http/Requests/ ← Optional: Form Request classes │ ├── Http/Resources/ ← Optional: API Resource transformers │ └── Routes/ │ └── api.php │ ├── Application/ ← Use cases. Orchestration only. │ ├── Commands/ │ │ └── RegisterDriverCommand.php ← data bag for write intent │ ├── Queries/ │ │ └── GetDriverProfileQuery.php ← data bag for read intent │ ├── Handlers/ │ │ └── RegisterDriverHandler.php ← thin delegator │ ├── Services/ │ │ └── RegisterDriverService.php ← Load → Act → Save → Dispatch │ ├── DTOs/ │ │ └── DriverProfileDto.php ← safe output for Presentation │ └── Listeners/ ← react to Domain Events │ ├── Domain/ ← Pure PHP. Zero Laravel. All rules here. │ ├── Entities/ │ │ └── Driver.php ← the aggregate root │ ├── ValueObjects/ │ │ ├── DriverStatus.php │ │ └── DriverLocation.php │ ├── Events/ │ │ └── DriverWentOnline.php │ ├── Exceptions/ │ │ └── InvalidDriverStatusTransition.php │ └── Repositories/ │ └── DriverRepository.php ← interface only, no SQL! │ └── Infrastructure/ ← Technical plumbing. Laravel lives here. ├── Persistence/Eloquent/ │ ├── Models/ │ │ └── DriverModel.php ← dumb Eloquent model, no business logic │ └── Repositories/ │ └── EloquentDriverRepository.php ← implements DriverRepository ├── Jobs/ ← async queue jobs └── Providers/ └── DriverServiceProvider.php ← binds interface → implementation

The rule about what goes in each folder

FolderThe one question to askIf yes → put it here
Domain/Entities/Is this the main business object with identity and rules?Driver, Merchant, Order
Domain/ValueObjects/Is this a concept described by its value, not identity? Is it immutable?DriverStatus, DriverLocation, KycStatus
Domain/Events/Is this something that happened in the business (past tense)?DriverWentOnline, MerchantKycApproved
Domain/Exceptions/Is this a violated business rule?InvalidDriverStatusTransition
Domain/Repositories/Is this a list of methods for finding/saving the aggregate? (interface only!)DriverRepository (interface)
Application/Commands/Is this a data object representing a write intent?RegisterDriverCommand, GoOnlineCommand
Application/Services/Is this orchestrating Load → Act → Save → Dispatch?RegisterDriverService
Application/DTOs/Is this a safe output shape for returning to the Presentation layer?DriverProfileDto
Infrastructure/Persistence/Is this touching Eloquent, SQL, or the database?EloquentDriverRepository, DriverModel
Presentation/Http/Is this handling HTTP input/output?DriverController, routes
✅ End of Step 4

All folders exist. All files are empty (or don't exist yet). You have a clear map of where everything will go. Now start filling it in — always starting from the Domain layer inward.

5
Domain Layer — start here, always
Write the Domain layer first

The Domain layer is the heart. Write it before the database, before the routes, before anything. This forces you to think about business rules before technical details. Pure PHP — no Laravel imports.

5a — Domain Exceptions first

Write your exceptions before your entities, because your entities will throw them.

// src/Driver/Domain/Exceptions/InvalidDriverStatusTransition.php
declare(strict_types=1);

namespace DeliveryApp\Driver\Domain\Exceptions;

use DeliveryApp\Shared\Domain\Exceptions\DomainException;  ← extends Shared
use DeliveryApp\Driver\Domain\ValueObjects\DriverStatus;

final class InvalidDriverStatusTransition extends DomainException
{
    public static function from(DriverStatus $from, DriverStatus $to): self
    {
        return new self(
            "Invalid transition: {$from->value} → {$to->value}"
        );
    }
}Domain/Exceptions/

5b — Value Objects next

// src/Driver/Domain/ValueObjects/DriverStatus.php
enum DriverStatus: string
{
    case Offline   = 'offline';
    case Online    = 'online';
    case Busy      = 'busy';
    case OnBreak   = 'on_break';
    case Suspended = 'suspended';

    public function isAvailable(): bool
    {
        return $this === self::Online;
    }

    public function canGoOnline(): bool
    {
        return match($this) {
            self::Offline, self::OnBreak => true,
            default => false,
        };
    }
}
// ↑ Business rules live on the Value Object itself.
// No if-strings scattered across the codebase.Domain/ValueObjects/DriverStatus.php

5c — Domain Events

// src/Driver/Domain/Events/DriverWentOnline.php
use DeliveryApp\Shared\Domain\Events\DomainEvent;  ← extends Shared
use DeliveryApp\Shared\Domain\ValueObjects\Uuid;
use DeliveryApp\Driver\Domain\ValueObjects\DriverLocation;

final class DriverWentOnline extends DomainEvent
{
    public function __construct(
        public readonly Uuid $driverId,
        public readonly DriverLocation $location,
    ) {
        parent::__construct();
    }

    public function eventName(): string { return 'driver.went_online'; }
    public function toArray(): array { return ['driver_id' => (string)$this->driverId, ...]; }
}Domain/Events/DriverWentOnline.php

5d — Repository Interface (no SQL!)

// src/Driver/Domain/Repositories/DriverRepository.php
// Pure PHP interface. ZERO Eloquent. ZERO SQL. The Domain says what it NEEDS.
interface DriverRepository
{
    public function findByUuid(Uuid $uuid): ?Driver;
    public function findByUserUuid(Uuid $userUuid): ?Driver;
    public function save(Driver $driver): void;
    public function findAvailableNear(Coordinates $origin, float $radiusKm, int $limit): iterable;
}Domain/Repositories/DriverRepository.php

5e — The Aggregate Root Entity (last, because it uses everything above)

// src/Driver/Domain/Entities/Driver.php
use DeliveryApp\Shared\Domain\ValueObjects\AggregateRoot;  ← Shared
use DeliveryApp\Shared\Domain\ValueObjects\Uuid;           ← Shared
use DeliveryApp\Driver\Domain\ValueObjects\DriverStatus;   ← own module
use DeliveryApp\Driver\Domain\Events\DriverWentOnline;     ← own module
use DeliveryApp\Driver\Domain\Exceptions\InvalidDriverStatusTransition;

final class Driver extends AggregateRoot
{
    private function __construct(
        public readonly Uuid   $id,
        public readonly Uuid   $userId,
        private DriverStatus   $status,
        private ?DriverLocation $lastLocation,
        private float           $rating,
        // ... other fields
    ) {}

    // ═══ Factory: create brand new driver ═══
    public static function register(Uuid $userId, string $fullName, ...): self
    {
        return new self(
            id:     Uuid::generate(),
            status: DriverStatus::Offline,  ← always starts Offline
            // ...
        );
    }

    // ═══ Factory: rebuild from database ═══
    public static function hydrate(Uuid $id, DriverStatus $status, ...): self
    {
        return new self($id, $status, ...);
    }

    // ═══ Business behaviour ═══
    public function goOnline(DriverLocation $location): void
    {
        if (! $this->status->canGoOnline()) {
            throw InvalidDriverStatusTransition::from($this->status, DriverStatus::Online);
        }
        $this->status       = DriverStatus::Online;
        $this->lastLocation = $location;
        $this->recordEvent(new DriverWentOnline($this->id, $location));
    }
}Domain/Entities/Driver.php
🧪 Write Domain unit tests NOW — before moving to Application layer

The Domain layer is pure PHP — tests run in milliseconds. Write tests for every entity method and every Value Object now. it('throws when going online while busy'), it('starts with Offline status'), it('records DriverWentOnline event'). If you skip tests here and move on, you'll never go back.

6
Application Layer
Write the Application layer

Commands, Handlers, Services, DTOs. This layer orchestrates use cases. It knows about the Domain. It doesn't know about HTTP or Eloquent. Write one use case at a time, from Command to DTO.

Write in this order for each use case

A
Command — the intent object
Plain PHP object. Readonly properties. Zero logic. Carries raw input from the Controller.
final class RegisterDriverCommand
{
    public function __construct(
        public readonly string $userUuid,
        public readonly int    $vehicleTypeId,
        public readonly string $fullName,
        public readonly string $licenseNo,
    ) {}
    // No methods. No logic. Just data.
}Application/Commands/RegisterDriverCommand.php
B
DTO — the output shape
Safe flat object. No Domain types exposed. Has a fromAggregate() factory method. The Controller receives this, never the raw Entity.
final class DriverProfileDto
{
    public function __construct(
        public readonly string  $uuid,
        public readonly string  $fullName,
        public readonly string  $status,  ← string, not DriverStatus enum
        public readonly float   $rating,
        public readonly ?array  $lastLocation,
    ) {}

    public static function fromAggregate(Driver $d): self
    {
        return new self(
            uuid:         (string) $d->id,
            fullName:     $d->fullName(),
            status:       $d->status()->value,
            rating:       $d->rating(),
            lastLocation: $d->lastLocation()?->toArray(),
        );
    }

    public function toArray(): array { return [...]; }
}Application/DTOs/DriverProfileDto.php
C
Service — the orchestrator (Load → Act → Save → Dispatch)
Depends on the Repository interface and DomainEventBus interface — never concrete classes. Always wraps writes in a DB transaction.
final class RegisterDriverService
{
    public function __construct(
        private readonly DriverRepository $repository,  ← interface
        private readonly DomainEventBus   $eventBus,    ← interface
    ) {}

    public function execute(RegisterDriverCommand $cmd): Driver
    {
        return DB::transaction(function() use ($cmd) {
            // 1. Create (Domain factory handles defaults)
            $driver = Driver::register(
                userId:       new Uuid($cmd->userUuid),
                vehicleTypeId:$cmd->vehicleTypeId,
                fullName:     $cmd->fullName,
                licenseNo:    $cmd->licenseNo,
            );
            // 2. Save
            $this->repository->save($driver);
            // 3. Dispatch events
            $this->eventBus->dispatchAll($driver->pullDomainEvents());

            return $driver;
        });
    }
}Application/Services/RegisterDriverService.php
D
Handler — the thin delegator
Unpacks the Command, calls the Service, converts the result to DTO. Usually 5–10 lines.
final class RegisterDriverHandler
{
    public function __construct(
        private readonly RegisterDriverService $service
    ) {}

    public function handle(RegisterDriverCommand $cmd): DriverProfileDto
    {
        $driver = $this->service->execute($cmd);
        return DriverProfileDto::fromAggregate($driver);
    }
}Application/Handlers/RegisterDriverHandler.php
💡 For read-only operations (Queries), the Service can be simpler

Query services often skip the Entity altogether and query the Eloquent model directly for performance. GetDriverProfileService might just call DriverModel::where('uuid', ...)->first() and map it to a DTO — without loading the full Domain Entity. This is acceptable and even recommended for read paths that don't need business rules.

7
Infrastructure Layer
Write the Infrastructure layer

This is where Laravel lives. Eloquent models, repository implementations, migrations, and service providers. It connects your pure Domain to the database.

7a — Write the migration first

# Create the migration
php artisan make:migration create_drivers_table
// database/migrations/xxxx_create_drivers_table.php
Schema::create('drivers', function (Blueprint $table) {
    $table->id();
    $table->uuid('uuid')->unique();
    $table->foreignId('user_id')->constrained();
    $table->string('full_name');
    $table->string('license_no')->unique();
    $table->string('status')->default('offline');
    $table->decimal('current_lat', 10, 7)->nullable();
    $table->decimal('current_lng', 10, 7)->nullable();
    $table->unsignedInteger('total_deliveries')->default(0);
    $table->decimal('rating', 4, 2)->default(0.0);
    $table->timestamps();
    $table->softDeletes();
});database/migrations/

7b — Eloquent Model (dumb data mapper)

// src/Driver/Infrastructure/Persistence/Eloquent/Models/DriverModel.php
final class DriverModel extends Model  ← extends Eloquent Model
{
    use SoftDeletes;
    protected $table    = 'drivers';
    protected $guarded  = [];
    protected $casts    = [
        'current_lat' => 'float',
        'current_lng' => 'float',
        'rating'      => 'float',
    ];

    // Auto-generate UUID on creation
    protected static function booted(): void
    {
        static::creating(fn(self $m) => $m->uuid ??= (string) Str::uuid());
    }

    // Relationships OK here — no business methods!
    public function user(): BelongsTo { return $this->belongsTo(User::class); }
}
// ↑ NO isAvailable(), NO canGoOnline(), NO goOnline() here
// Those live in Domain/Entities/Driver.php onlyInfrastructure/Eloquent/Models/DriverModel.php

7c — Eloquent Repository (implements the Domain interface)

// src/Driver/Infrastructure/Persistence/Eloquent/Repositories/EloquentDriverRepository.php
final class EloquentDriverRepository implements DriverRepository  ← interface from Domain
{
    // READ: DB row → Domain Entity
    public function findByUuid(Uuid $uuid): ?Driver
    {
        $model = DriverModel::query()->where('uuid', $uuid->value)->first();
        return $model ? $this->toDomain($model) : null;
    }

    // WRITE: Domain Entity → DB row
    public function save(Driver $driver): void
    {
        $model = DriverModel::firstOrNew(['uuid' => (string)$driver->id]);
        $model->fill([
            'user_id'          => $this->resolveUserId($driver->userId),
            'full_name'        => $driver->fullName(),
            'status'           => $driver->status()->value,  ← enum → string
            'current_lat'      => $driver->lastLocation()
                                       ?->coordinates->latitude,
            'current_lng'      => $driver->lastLocation()
                                       ?->coordinates->longitude,
            'rating'           => $driver->rating(),
        ]);
        $model->save();
    }

    // HYDRATION: Eloquent model → Domain Entity
    private function toDomain(DriverModel $m): Driver
    {
        return Driver::hydrate(
            id:     new Uuid((string)$m->uuid),
            status: DriverStatus::from((string)$m->status),  ← string → enum
            rating: (float)$m->rating,
            // ... other fields
        );
    }
}Infrastructure/Repositories/EloquentDriverRepository.php

7d — Service Provider (the wiring)

// src/Driver/Infrastructure/Providers/DriverServiceProvider.php
final class DriverServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Bind interface → implementation
        $this->app->bind(
            DriverRepository::class,
            EloquentDriverRepository::class,
        );
    }

    public function boot(): void
    {
        // Register module routes
        Route::middleware('api')
              ->prefix('api/v1')
              ->group(base_path('src/Driver/Presentation/Routes/api.php'));
    }
}Infrastructure/Providers/DriverServiceProvider.php
8
Presentation Layer — last to write
Write the Presentation layer

Controllers and routes. Always the last layer you write for each feature — because everything they depend on (Handler → Service → Entity → Repository) must exist first. Controllers must be thin: validate, delegate, respond.

The 3-line controller rule

Every controller action should do exactly three things, nothing more:

1
Validate

Check raw HTTP input format. Required fields, data types, basic uniqueness. Nothing business-related.

2
Delegate

Package input into a Command/Query. Call the Handler. The Controller's job is done — it waits.

3
Respond

Format the DTO from the Handler into a JSON response with the correct HTTP status code.

A complete thin controller

// src/Driver/Presentation/Http/Controllers/DriverController.php
final class DriverController extends Controller
{
    public function __construct(
        private readonly RegisterDriverHandler       $registerHandler,
        private readonly GoOnlineHandler            $onlineHandler,
        private readonly GetDriverProfileHandler    $profileHandler,
    ) {}

    // ── POST /api/v1/drivers ──
    public function store(Request $request): JsonResponse
    {
        // Step 1: validate raw HTTP format only
        $v = $request->validate([
            'user_uuid'   => ['required', 'uuid'],
            'full_name'   => ['required', 'string', 'max:255'],
            'license_no'  => ['required', 'string', 'unique:drivers,license_no'],
            'vehicle_type_id' => ['required', 'integer', 'exists:vehicle_types,id'],
        ]);

        // Step 2: delegate to Handler via Command
        $dto = $this->registerHandler->handle(new RegisterDriverCommand(
            userUuid:      $v['user_uuid'],
            vehicleTypeId: (int) $v['vehicle_type_id'],
            fullName:      $v['full_name'],
            licenseNo:     $v['license_no'],
        ));

        // Step 3: respond
        return response()->json(['data' => $dto->toArray()], 201);
    }

    // ── POST /api/v1/drivers/me/online ──
    public function goOnline(Request $request): JsonResponse
    {
        $v = $request->validate([
            'lat'     => ['required', 'numeric', 'between:-90,90'],
            'lng'     => ['required', 'numeric', 'between:-180,180'],
            'heading' => ['nullable', 'numeric'],
        ]);

        $driverUuid = $this->resolveDriverUuid($request);

        $this->onlineHandler->handle(new GoOnlineCommand(
            driverUuid: $driverUuid,
            lat: (float) $v['lat'],
            lng: (float) $v['lng'],
        ));

        return response()->json(['data' => ['status' => 'online']], 202);
    }
}Presentation/Http/Controllers/DriverController.php

Routes file — co-located with the module

// src/Driver/Presentation/Routes/api.php
Route::middleware('auth:sanctum')->group(function () {

    // Admin-only actions
    Route::middleware('role:admin')->group(function () {
        Route::post('/drivers', [DriverController::class, 'store']);
        Route::get('/admin/drivers/nearby', [DriverController::class, 'nearby']);
    });

    // Driver self-service actions
    Route::prefix('drivers/me')->group(function () {
        Route::get('/',        [DriverController::class, 'me']);
        Route::post('/online', [DriverController::class, 'goOnline']);
        Route::post('/offline',[DriverController::class, 'goOffline']);
        Route::post('/ping',   [DriverController::class, 'ping']);
    });
});Presentation/Routes/api.php
⚠️ What must NEVER be in a Controller

No Eloquent queries. No Driver::where(...). No business rules. No if ($driver->status === 'busy'). No email sending. No Mail::send(...). No event dispatching directly. No event(new DriverWentOnline(...)). If you see any of these in a Controller, move them to the right layer.

9
Integration
Wire everything together and test

Register the provider, run the migration, verify the full flow end-to-end. Write three types of tests: unit (Domain), integration (Repository), and feature (API).

The end-to-end verification checklist

  • SharedKernelServiceProvider registered first in bootstrap/providers.php
  • DriverServiceProvider registered after Shared
  • Migration runs: php artisan migrate
  • Routes appear: php artisan route:list | grep driver
  • Container resolves: php artisan tinker → app(DriverRepository::class)
  • POST /api/v1/drivers returns 201 with driver profile JSON
  • POST /api/v1/drivers/me/online returns 202
  • Sending invalid status transition returns 422 with the business error message

Three types of tests you need

🧪 Unit Tests — Domain only

No database. No Laravel. Milliseconds per test. Test every Entity method, every Value Object, every Domain Event.

// tests/Unit/Driver/Domain/
it('starts with Offline status', function() {
    $d = Driver::register(...);
    expect($d->status())->toBe(DriverStatus::Offline);
});

it('throws when going online while busy', function() {
    $d = Driver::register(...);
    $d->markBusy();
    expect(fn() => $d->goOnline($loc))
        ->toThrow(InvalidDriverStatusTransition::class);
});
🔗 Integration Tests — Repository

Uses a test database. Tests that save() and findByUuid() work correctly — that hydration round-trips without data loss.

// tests/Integration/Driver/
it('persists and retrieves driver', function() {
    $repo   = app(DriverRepository::class);
    $driver = Driver::register(...);
    $repo->save($driver);
    $found  = $repo->findByUuid($driver->id);
    expect($found->id->equals($driver->id))->toBeTrue();
});
🌐 Feature Tests — full API

Full HTTP request through the whole stack. Tests the contract your API clients depend on.

// tests/Feature/Driver/
it('registers a driver via API', function() {
    $user = User::factory()->create();

    $this->actingAs($user)
         ->postJson('/api/v1/drivers', [...])
         ->assertCreated()
         ->assertJsonPath('data.status', 'offline');
});

Running tests

# Run all tests
php artisan test

# Run only Domain unit tests (fastest — no DB)
./vendor/bin/pest tests/Unit --filter=Driver

# Run with coverage
./vendor/bin/pest --coverage

# Run architecture tests (enforce DDD rules)
./vendor/bin/pest --filter=arch

Optional: Enforce DDD rules in tests

// tests/Arch/DddRulesTest.php — automatic architecture enforcement

// Domain must not import from Laravel
arch()->expect('DeliveryApp\Driver\Domain')
      ->toUseNothing()->except('DeliveryApp\Shared');

// Controllers must not use Eloquent directly
arch()->expect('DeliveryApp\Driver\Presentation')
      ->not()->toUse('Illuminate\Database\Eloquent\Model');

// Services must not extend Model
arch()->expect('DeliveryApp\Driver\Application')
      ->not()->toExtend('Illuminate\Database\Eloquent\Model');
// These run in CI and fail the build if any rule is brokenArchitecture tests
10
Scale Up
Add more modules — and keep them isolated

Once the first module works, you have a template. Every new module follows the same steps 4–9. The key challenge: keep modules from depending on each other.

The golden rule of multi-module apps

Modules communicate via UUIDs and Domain Events only.
Never import one module's Entity into another module's code.

// ❌ WRONG: Delivery imports Driver Entity
namespace DeliveryApp\Delivery;

use DeliveryApp\Driver\Domain\Entities\Driver;
// ↑ Delivery is now coupled to Driver's internals.
// Changing Driver breaks Delivery.

class Order {
    private ?Driver $assignedDriver; ← ❌
}
// ✅ CORRECT: Reference by UUID only
namespace DeliveryApp\Delivery;

use DeliveryApp\Shared\Domain\ValueObjects\Uuid;
// ↑ Only depends on Shared — always safe

class Order {
    private ?Uuid $assignedDriverId; ← ✅
    // Just the ID. Delivery doesn't need Driver's rules.
}

Cross-module communication via Events

// When a delivery is created, the Merchant module needs to deduct credit.
// But Delivery must NOT import Merchant's Entity.
// Solution: Domain Events + Listeners

// Delivery module fires an event:
$this->recordEvent(new OrderCreated(
    orderId:    $this->id,
    merchantId: $this->merchantId,  ← just a Uuid
    amount:     $this->deliveryFee, ← a Money VO from Shared
));

// Merchant module listens, using its own repository to load its Entity:
class DeductCreditOnOrderCreatedListener
{
    public function handle(OrderCreated $event): void
    {
        $merchant = $this->merchantRepository->findByUuid($event->merchantId);
        $merchant->deductCredit($event->amount);  ← Merchant's own method
        $this->merchantRepository->save($merchant);
    }
}
// The Delivery module has no idea the Merchant module exists.
// The Merchant module has no idea the Delivery module exists.
// They only share the OrderCreated event class.Cross-module via Events

The order in which to build modules

1
Identity / Auth module (or use Laravel's built-in User)
Everything else references users by UUID. Build or configure authentication first so all other modules can use auth:sanctum middleware and $request->user().
2
Your core domain modules (Driver, Merchant)
The modules that represent the main actors of your business. Build independently — they don't depend on each other.
3
Transaction modules (Delivery, Order)
Modules that coordinate between the core actors. They reference Driver and Merchant by UUID. Build after the core modules are stable.
4
Support modules (Payment, Notification, Tracking)
Modules that react to events from other modules. Often implemented purely as Event Listeners. Build last — they have no knowledge requirements, just event subscriptions.

Your complete project after all modules

src/ ├── Shared/ ← built in Step 3, never changes │ ├── Domain/ │ └── Infrastructure/ │ ├── Driver/ ← built in Steps 4-9 │ ├── Presentation/ │ ├── Application/ │ ├── Domain/ │ └── Infrastructure/ │ ├── Merchant/ ← repeat Steps 4-9 │ ├── Presentation/ │ ├── Application/ │ ├── Domain/ │ └── Infrastructure/ │ ├── Delivery/ ← repeat Steps 4-9 │ ├── Presentation/ │ ├── Application/ │ ├── Domain/ │ └── Infrastructure/ │ └── Payment/ ← repeat Steps 4-9 ├── Application/Listeners/ ← mostly event listeners ├── Domain/ └── Infrastructure/

The mental checklist for every new feature

  • 1
    Which module does this belong to?
  • 2
    Which Entity owns this behaviour? Or is it a new Entity?
  • 3
    Write the Domain method + unit test first.
  • 4
    Write the Command (if write) or Query (if read).
  • 5
    Write the Service (Load → Act → Save → Dispatch).
  • 6
    Write the Handler (3–5 lines, thin delegator).
  • 7
    Does something else in the system need to react? Write a Domain Event + Listener.
  • 8
    Does the Repository need a new method? Add to interface, implement in Eloquent Repository.
  • 9
    Write the Controller action (validate → delegate → respond).
  • 10
    Add the route. Write a Feature test. Run all tests.
🎯 You're ready to start

Step 1 is the only step you can do right now — no computer needed. Take your application idea, grab a notebook, and write down your bounded contexts, business rules, and domain events. The code follows naturally from that clarity.

Remember: DDD is not a set of folders. It's the discipline of making your code mirror the real business — so that when the business changes, the code changes in exactly the right place, in exactly the right way.