Complete Beginner's Guide

Domain-Driven Design
in Laravel

You know normal Laravel MVC. Now learn how large companies structure their applications using DDD — explained step by step, using your actual Merchant and Driver code, with zero assumed knowledge.

📦 Merchant module — KYC, billing, delivery eligibility
🚗 Driver module — GPS, status management, dispatch
🏗️ 4 layers — Presentation → Application → Domain → Infrastructure
🔁 CQRS pattern — Commands, Queries, Handlers
Chapter 1
What is Domain-Driven Design?

Before we look at any code, let's understand the idea behind DDD — because understanding the "why" makes the "how" obvious.

The plain English explanation

Domain means "the business problem you're solving." In your case, the domain is delivery logistics — merchants placing orders, drivers accepting them, packages being tracked.

Driven means the code structure is shaped by the business rules, not by the technology. You don't start with "how does Laravel do this?" You start with "how does the delivery business work?" and then write code that mirrors that.

Design means making deliberate architectural decisions — where each piece of logic lives, how the layers communicate, and how to keep different parts of the system independent from each other.

🏦 The Bank Analogy

Imagine a bank. It has departments: Teller (accepts your request), Compliance (checks if it's allowed), Vault (actually stores the money), IT Systems (the computers that run everything). Each department has one job. The Teller doesn't know the vault combination. The Vault doesn't care what the customer's name is. DDD structures code the same way — each layer has one clear responsibility.

What problem does it solve?

In a normal Laravel MVC app, your controllers often grow into "God classes" — enormous files that do everything: validate input, run business rules, query the database, send emails, format responses. This is called the Big Ball of Mud problem.

Here's what that looks like when it goes wrong:

// ❌ A "Big Ball of Mud" MVC Controller (what you want to AVOID)
class DriverController
{
    public function goOnline(Request $request)
    {
        // Validation mixed in...
        if (!$request->has('lat')) abort(422);

        // Business rule mixed in...
        $driver = Driver::find($request->user()->driver_id);
        if ($driver->status === 'busy') {
            abort(422, 'Cannot go online while busy');
        }

        // DB update mixed in...
        $driver->update(['status' => 'online', 'lat' => $request->lat]);

        // Notification mixed in...
        Notification::send($nearbyMerchants, new DriverOnlineNotification());

        // Analytics mixed in...
        Analytics::track('driver_went_online', [...]);

        return response()->json(['status' => 'online']);
    }
}
❌ MVC anti-pattern

This works for a small app. But as the app grows, this controller becomes 2,000 lines. Every new feature breaks three existing ones. You're scared to change anything.

Here's what the same thing looks like in DDD — your actual code:

// ✅ DDD version — each layer has exactly one job
class DriverController
{
    public function goOnline(Request $request): JsonResponse
    {
        // 1. Presentation: validate raw HTTP input format only
        $v = $request->validate([
            'lat' => ['required', 'numeric', 'between:-90,90'],
            'lng' => ['required', 'numeric', 'between:-180,180'],
        ]);

        // 2. Application: create intent (Command) and fire it
        $this->onlineHandler->handle(new GoOnlineCommand(
            driverUuid: $self->uuid,
            location: new Coordinates($v['lat'], $v['lng']),
        ));

        // 3. Presentation: format the response
        return response()->json(['data' => ['status' => 'online']]);
        // Business rules? → Domain Entity
        // DB query? → Repository
        // Notifications? → Event Listeners
        // Analytics? → Event Listeners
    }
}
✅ DDD — Presentation/Http/Controllers/DriverController.php

The core idea in one sentence

"Put business rules in the Domain, database logic in Infrastructure, orchestration in Application, and HTTP handling in Presentation. Each layer knows nothing about the layers above it."

Chapter 2
Why not just use normal Laravel MVC?

MVC is great for simple apps. DDD is great for complex ones that need to grow. Let's compare them side by side.

Topic Normal Laravel MVC DDD (your project)
Business rules Scattered across Controllers, Models, and helper functions Centralized in Domain Entities. One place. Always enforced.
Testing Need to boot the whole Laravel app to test a business rule Domain layer is pure PHP — tests run without DB or HTTP in milliseconds
Changing database Business logic and DB queries are mixed — hard to swap Repository interface stays the same — just swap the implementation
Adding a new feature Risk of breaking existing features since code is tightly coupled Add a new Command + Handler + Service. Existing code unaffected.
Multiple entry points Logic in Controller can't be reused from Queue jobs or CLI commands Handler/Service can be called from HTTP, Queue, CLI — same code
Team development Two developers edit the same Controller — conflicts every day Each feature is isolated — teams work on separate Handlers/Services
Complexity Simple to start, exponentially harder as app grows More setup upfront, linear complexity growth as app grows
💡 When to use which?

Use plain MVC for: personal projects, prototypes, simple CRUD apps, small team with 5 models or fewer. Use DDD for: complex business logic, multiple teams, apps that need to last 5+ years, delivery platforms, fintech, healthcare, e-commerce with complex pricing rules.

Chapter 3
Your project — a delivery platform

Your project is named DeliveryApp. It has two bounded contexts (independent business departments): Merchant and Driver.

📦 What is a Bounded Context?

A bounded context is an independent business area with its own language, rules, and data. Think of it as a company department. HR has its own rules, Finance has its own rules — they don't mix. In your project: the Merchant context has KYC rules, billing modes, and credit limits. The Driver context has GPS tracking, status transitions, and dispatch logic. They are completely independent — you could delete the Driver module and the Merchant module would still work.

📦 Merchant Module

Represents restaurants, shops, or businesses that want to send deliveries.

Key concepts:

KYC verification Billing mode Credit limit Delivery eligibility

State machine: Pending → Submitted → Approved/Rejected

Main Entity: Merchant.php

🚗 Driver Module

Represents couriers who receive and fulfill delivery orders.

Key concepts:

GPS location Status management Dispatch eligibility Performance tracking

Status flow: Offline → Online → Busy → Online

Main Entity: Driver.php

Both modules follow exactly the same 4-layer DDD pattern. Once you understand one, you understand both.

Architecture
The 4 layers explained

Every file in your project belongs to one of four layers. Each layer has one job, and they can only communicate in one direction: from outside in.

🏨 Hotel Analogy

Presentation = Front Desk. Greets guests, takes requests, checks IDs (validates format). Doesn't make decisions. Application = Hotel Manager. Gets the booking from the front desk, checks room availability (repository), updates the system, sends confirmation (events). Domain = Booking Rules. The rulebook: "You can't book a room that's already occupied." "VIP guests get 10% discount." No technology, just rules. Infrastructure = IT Department. The computers, the database, the payment system. Implements whatever the manager needs.

What would break without each layer?

If you removed...What breaksReal impact
Presentation layer No HTTP API. Domain/Application logic can't be reached from outside. Add CLI/GraphQL entry points — you'd have to rewrite all routing and validation
Application layer Controllers would call Repositories directly. Business orchestration lives in Controllers. Queue jobs and CLI commands can't reuse use-case logic. Code duplicated everywhere.
Domain layer Business rules scattered in Controllers, Services, database triggers. No single source of truth. Change a rule (e.g. "busy drivers can't go offline") → hunt through 30 files to update it.
Infrastructure layer Domain would import Eloquent directly. Domain coupled to MySQL. Want to add Redis for geolocation? Rewrite Domain code. Impossible to unit test without DB.
Architecture
Folder structure — every file explained

Both the Driver and Merchant modules follow identical folder structures. Here is every folder and file, with its purpose.

Driver/ module

Driver/ ├── Presentation/ ← HTTP boundary │ ├── Http/Controllers/ │ │ └── DriverController.php │ ├── Http/Middleware/ ← (empty, ready) │ ├── Http/Requests/ ← (empty, ready) │ ├── Http/Resources/ ← (empty, ready) │ └── Routes/ │ └── api.php │ ├── Application/ ← Use-case layer │ ├── Commands/ │ │ ├── RegisterDriverCommand.php │ │ ├── GoOnlineCommand.php │ │ ├── GoOfflineCommand.php │ │ └── UpdateDriverLocationCommand.php │ ├── Queries/ │ │ ├── GetDriverProfileQuery.php │ │ └── ListNearbyDriversQuery.php │ ├── Handlers/ │ │ ├── RegisterDriverHandler.php │ │ ├── GoOnlineHandler.php │ │ ├── GoOfflineHandler.php │ │ ├── UpdateDriverLocationHandler.php │ │ ├── GetDriverProfileHandler.php │ │ └── ListNearbyDriversHandler.php │ ├── Services/ │ │ ├── RegisterDriverService.php │ │ ├── UpdateDriverStatusService.php │ │ ├── UpdateDriverLocationService.php │ │ └── ListNearbyDriversService.php │ ├── DTOs/ │ │ └── DriverProfileDto.php │ └── Listeners/ ← (empty, ready) │ ├── Domain/ ← Business rules │ ├── Entities/ │ │ └── Driver.php ← the aggregate root │ ├── ValueObjects/ │ │ ├── DriverStatus.php ← offline/online/busy… │ │ └── DriverLocation.php ← GPS snapshot │ ├── Events/ │ │ ├── DriverWentOnline.php │ │ ├── DriverWentOffline.php │ │ └── DriverLocationUpdated.php │ ├── Exceptions/ │ │ └── InvalidDriverStatusTransition.php │ ├── Repositories/ │ │ └── DriverRepository.php ← interface only! │ └── Services/ ← (empty, ready) │ └── Infrastructure/ ← Tech plumbing ├── Persistence/Eloquent/ │ ├── Models/ │ │ └── DriverModel.php │ └── Repositories/ │ └── EloquentDriverRepository.php ├── Persistence/Mappers/ ← (empty, ready) ├── Broadcasting/ ← (empty, ready) ├── Jobs/ ← (empty, ready) └── Providers/ └── DriverServiceProvider.php

Merchant/ module

Merchant/ ├── Presentation/ │ ├── Http/Controllers/ │ │ └── MerchantController.php │ ├── Http/Middleware/ │ ├── Http/Requests/ │ ├── Http/Resources/ │ └── Routes/ │ └── api.php │ ├── Application/ │ ├── Commands/ │ │ ├── RegisterMerchantCommand.php │ │ ├── ApproveMerchantKycCommand.php │ │ ├── RejectMerchantKycCommand.php │ │ └── UpdateMerchantProfileCommand.php │ ├── Queries/ │ │ ├── GetMerchantQuery.php │ │ └── ListPendingMerchantsQuery.php │ ├── Handlers/ │ │ ├── RegisterMerchantHandler.php │ │ ├── ApproveMerchantKycHandler.php │ │ ├── RejectMerchantKycHandler.php │ │ ├── UpdateMerchantProfileHandler.php │ │ ├── GetMerchantHandler.php │ │ └── ListPendingMerchantsHandler.php │ ├── Services/ │ │ ├── RegisterMerchantService.php │ │ ├── ApproveMerchantKycService.php │ │ ├── RejectMerchantKycService.php │ │ └── UpdateMerchantProfileService.php │ ├── DTOs/ │ │ └── MerchantDto.php │ └── Listeners/ │ ├── Domain/ │ ├── Entities/ │ │ └── Merchant.php ← the aggregate root │ ├── ValueObjects/ │ │ ├── KycStatus.php ← pending/submitted/approved/rejected │ │ └── BillingMode.php ← prepaid/postpaid │ ├── Events/ │ │ ├── MerchantRegistered.php │ │ ├── MerchantKycApproved.php │ │ └── MerchantKycRejected.php │ ├── Exceptions/ │ │ └── InvalidKycTransitionException.php │ ├── Repositories/ │ │ └── MerchantRepository.php ← interface only! │ └── Services/ │ └── Infrastructure/ ├── Persistence/Eloquent/ │ ├── Models/ │ │ └── MerchantModel.php │ └── Repositories/ │ └── EloquentMerchantRepository.php ├── Persistence/Mappers/ ├── Broadcasting/ ├── Jobs/ └── Providers/ └── MerchantServiceProvider.php
🔑 Key insight

The color coding above matches the layers: Blue = Presentation, Green = Application, Amber = Domain, Pink = Infrastructure. Notice that Domain/Repositories/ contains only an interface — no SQL. The actual SQL lives in Infrastructure/Repositories/. This is intentional and fundamental to DDD.

Architecture
The dependency rule — the golden rule of DDD

This is the most important rule in the entire architecture. If you remember nothing else, remember this.

Dependencies only flow inward.
Infrastructure → Application → Domain
The Domain never imports from Application or Infrastructure.

// ✅ CORRECT: Infrastructure imports from Domain
namespace DeliveryApp\Driver\Infrastructure\Persistence\Eloquent\Repositories;

use DeliveryApp\Driver\Domain\Entities\Driver;           // ✅ Domain ← Infrastructure
use DeliveryApp\Driver\Domain\Repositories\DriverRepository; // ✅ Implements Domain interface
use Illuminate\Database\Eloquent\Model;                    // ✅ OK in Infrastructure

class EloquentDriverRepository implements DriverRepository { ... }

// ❌ WRONG: Domain importing from Infrastructure (NEVER DO THIS)
namespace DeliveryApp\Driver\Domain\Entities;

use Illuminate\Database\Eloquent\Model;                    // ❌ Domain importing Laravel!
use DeliveryApp\Driver\Infrastructure\Persistence\...;      // ❌ Domain knows about DB!
Dependency rule

Why does this matter? If the Domain depends on Eloquent, you cannot test the Domain without a database. You cannot swap MySQL for PostgreSQL without changing Domain code. You cannot run the Domain in a microservice without taking all of Laravel with it. The Domain must be a "technology-free zone."

Domain Layer
Entities & Aggregates

An Entity is an object with a unique identity and behaviour. An Aggregate is an Entity that owns a cluster of related objects and protects their consistency. Driver.php and Merchant.php are both Aggregates.

What is an Entity? What is an Aggregate?

Entity

Has a unique ID (UUID). Has identity — two entities with the same data but different IDs are NOT equal. Has behaviour — methods that enforce rules.

📋 Analogy

A person's passport. It has a unique number, and rules: it expires, it can be suspended, it cannot be transferred. The Entity enforces all its own rules.

Aggregate (Root)

An Entity that owns other objects. It's the single entry point to modify anything inside it. No other code can modify its internals directly — they must go through the Aggregate's methods.

📋 Analogy

A bank account. You can't directly modify the balance field — you go through deposit() or withdraw(). The account is the Aggregate Root; the transactions are owned by it.

Driver entity — full walkthrough

Let's read Driver/Domain/Entities/Driver.php piece by piece, like a story:

/**
 * Driver aggregate root.
 *
 * Owns: operational status, last GPS location, performance counters.
 * Does NOT own: GPS ping history (Tracking context), auth (Identity context),
 *               delivery assignments (Delivery context references Driver by UUID only).
 */
final class Driver extends AggregateRoot
{
    // Private constructor — you can ONLY create a Driver through
    // the static factory methods below. This prevents invalid state.
    private function __construct(
        public readonly Uuid $id,
        public readonly Uuid $userId,
        public readonly int $vehicleTypeId,
        private string $fullName,
        private DriverStatus $status,
        private ?DriverLocation $lastLocation,
        private float $rating,
        private int $totalDeliveries,
        private int $failedDeliveries,
    ) {}
Driver/Domain/Entities/Driver.php

Notice: $status is private. External code cannot do $driver->status = 'online'. They MUST call $driver->goOnline(), which enforces the rules.

// ═══ FACTORY METHOD #1: Creating a brand new driver ═══
// Used by: RegisterDriverService when a new driver registers
public static function register(
    Uuid $userId,
    int $vehicleTypeId,
    string $fullName,
    string $licenseNo,
    string $vehiclePlate,
): self
{
    return new self(
        id: Uuid::generate(),          // auto-generate UUID
        status: DriverStatus::Offline,  // always starts Offline!
        lastLocation: null,            // no location yet
        rating: 0.0,
        totalDeliveries: 0,
        failedDeliveries: 0,
        // ... other fields
    );
}

// ═══ FACTORY METHOD #2: Rebuilding from database ═══
// Used by: EloquentDriverRepository.toDomain() when loading from DB
public static function hydrate(Uuid $id, DriverStatus $status, /* ... */): self
{
    return new self($id, $status, /* ... */); // restore existing state
}Driver/Domain/Entities/Driver.php
💡 Two factory methods — why?

register() creates a new driver — it generates a UUID and sets defaults. hydrate() restores an existing driver from the database — it uses the stored UUID and values. Both are static methods on the Entity. This is the Factory pattern inside an Aggregate.

// ═══ BUSINESS METHOD: Going online ═══
// This is where the business rule lives!
public function goOnline(DriverLocation $location): void
{
    // RULE: Can only go online from Offline or OnBreak status
    if (! $this->status->canGoOnline()) {
        throw InvalidDriverStatusTransition::from(
            $this->status,           // current: e.g. Busy
            DriverStatus::Online    // attempted: Online
        );
        // Error message: "Invalid driver status transition: busy -> online"
    }

    // If allowed, update state
    $this->status = DriverStatus::Online;
    $this->lastLocation = $location;

    // Record the event (like writing in a diary: "this happened")
    $this->recordEvent(new DriverWentOnline($this->id, $location));
}

// ═══ BUSINESS METHOD: Going offline ═══
public function goOffline(): void
{
    // RULE: A driver mid-delivery cannot just go offline
    if ($this->status === DriverStatus::Busy) {
        throw InvalidDriverStatusTransition::from(
            $this->status, DriverStatus::Offline
        );
    }
    $this->status = DriverStatus::Offline;
    $this->recordEvent(new DriverWentOffline($this->id));
}

// ═══ COMPUTED PROPERTY ═══
// No need to store this in the DB — calculate on the fly
public function successRate(): float
{
    if ($this->totalDeliveries === 0) return 0.0;
    return 1.0 - ($this->failedDeliveries / $this->totalDeliveries);
}Driver/Domain/Entities/Driver.php

Merchant entity — KYC state machine

The Merchant entity enforces the KYC (Know Your Customer) verification flow. A merchant must go through a specific sequence of steps before they can create deliveries:

Pending
→ submitKyc()
→ approveKyc()
Approved ✓
→ rejectKyc(reason)
Rejected ✗
public function approveKyc(): void
{
    // RULE: Can only approve a SUBMITTED application
    // Cannot approve Pending (not reviewed) or Rejected (must re-submit)
    if ($this->kycStatus !== KycStatus::Submitted) {
        throw InvalidKycTransitionException::from(
            $this->kycStatus, KycStatus::Approved
        );
    }
    $this->kycStatus = KycStatus::Approved;
    $this->kycNotes = null;
    $this->recordEvent(new MerchantKycApproved($this->id));
    // This event triggers: welcome email, dashboard unlock, etc.
}

// ═══ BUSINESS QUERY: Can this merchant place orders? ═══
public function canCreateDeliveries(): bool
{
    return $this->isActive
        && $this->kycStatus === KycStatus::Approved;
    // Must be: account active AND KYC approved
    // Rejected KYC? → false
    // Pending KYC? → false
    // Account banned? → false
}Merchant/Domain/Entities/Merchant.php
✅ Why business rules belong in the Entity

canCreateDeliveries() uses only the Entity's own data. It doesn't need the database. It doesn't need Laravel. You can call it in a unit test in 1ms without any setup. If you put this check in the Controller, you'd have to repeat it everywhere (queue jobs, CLI commands, admin actions). In the Entity, it's written once and enforced everywhere.

Domain Layer
Value Objects

A Value Object represents a concept, not a unique "thing". Two Value Objects with the same values are equal — unlike Entities, which are identified by their ID. Value Objects are always immutable — they cannot change after creation.

💵 Analogy — banknotes vs passports

If you have a £10 note and I have a £10 note, they are interchangeable — the value is what matters, not which specific note. That's a Value Object. But your passport is unique — even if two passports have the same photo, they belong to different people. That's an Entity.

DriverStatus — an enum Value Object

// Driver/Domain/ValueObjects/DriverStatus.php
// An enum IS a Value Object — immutable, value-defined, with behaviour
enum DriverStatus: string
{
    case Offline   = 'offline';
    case Online    = 'online';
    case Busy      = 'busy';
    case OnBreak   = 'on_break';
    case Suspended = 'suspended';

    // BUSINESS RULE: Is the driver eligible for new delivery offers?
    public function isAvailable(): bool
    {
        return $this === self::Online; // ONLY Online status = available
    }

    // BUSINESS RULE: Can the driver switch to Online?
    public function canGoOnline(): bool
    {
        return match($this) {
            self::Offline, self::OnBreak => true,
            // Busy? Suspended? → cannot go online
            default => false,
        };
    }
}Driver/Domain/ValueObjects/DriverStatus.php
Without Value ObjectWith Value Object (your code)
if ($driver->status === 'online') { ... }
Plain string. Typo = bug. No IDE help. Rule copied everywhere.
if ($driver->status()->isAvailable()) { ... }
Type-safe. IDE autocomplete. Rule defined once on the enum.
Can set any string: $driver->status = 'onlne' (typo) Compiler error if invalid: DriverStatus::from('onlne') throws ValueError

DriverLocation — a rich Value Object

// Driver/Domain/ValueObjects/DriverLocation.php
// Bundles 6 related GPS fields into one concept
final class DriverLocation
{
    public function __construct(
        public readonly Coordinates $coordinates, // lat + lng together
        public readonly DateTimeImmutable $recordedAt,
        public readonly ?float $heading = null,
        public readonly ?float $speedKmh = null,
        public readonly ?int $batteryPct = null,
        public readonly ?float $accuracyMeters = null,
    ) {}

    // BUSINESS RULE: Is this location snapshot too old to trust?
    public function isStale(int $maxAgeSeconds): bool
    {
        $now = new DateTimeImmutable();
        return ($now->getTimestamp() - $this->recordedAt->getTimestamp()) > $maxAgeSeconds;
        // Caller: if ($location->isStale(30)) { warn: stale GPS data }
    }
}Driver/Domain/ValueObjects/DriverLocation.php

Instead of passing 6 separate $lat, $lng, $heading, $speedKmh, $batteryPct, $accuracy parameters around your entire codebase, you pass one DriverLocation object. It's cleaner, type-safe, and carries its own business rules (isStale()).

KycStatus and BillingMode — simple enum Value Objects

// Merchant/Domain/ValueObjects/KycStatus.php
enum KycStatus: string
{
    case Pending   = 'pending';    // Just registered, KYC not submitted
    case Submitted = 'submitted';  // Documents uploaded, awaiting review
    case Approved  = 'approved';   // Verified — can create deliveries
    case Rejected  = 'rejected';   // Failed verification
}

// Merchant/Domain/ValueObjects/BillingMode.php
enum BillingMode: string
{
    case Prepaid  = 'prepaid';   // Pay before delivering
    case Postpaid = 'postpaid';  // Pay after delivering (credit)
}Merchant/Domain/ValueObjects/
Domain Layer
Domain Events

A Domain Event is a record that something important happened in the business. "Driver went online." "Merchant KYC was approved." Events are facts — past tense, immutable. They let different parts of the system react independently.

📣 The Town Crier Analogy

When the king signs a new law, the town crier announces it to everyone. Different people react differently: merchants adjust their prices, lawyers update their books, citizens celebrate. No one tells the king "I received your announcement." Domain Events work the same way: one thing happens, many parts of the system react independently — and the Entity that fired the event doesn't need to know about any of them.

How events work in your project

1
Entity records the event
Driver.goOnline() calls $this->recordEvent(new DriverWentOnline($this->id, $location)). The event is stored in memory on the Entity object — NOT dispatched yet.
2
Service pulls and dispatches all events
After saving to the DB, RegisterDriverService calls $this->eventBus->dispatchAll($driver->pullDomainEvents()). This is safe — the DB is already committed before events fire.
3
Listeners react independently
The Tracking context can store GPS history. The Notification service can alert nearby merchants. Analytics can log the event. Each Listener is independent — they don't know about each other.
// Driver/Domain/Events/DriverWentOnline.php
final class DriverWentOnline extends DomainEvent
{
    public function __construct(
        public readonly Uuid $driverId,
        public readonly DriverLocation $location,
    ) {
        parent::__construct(); // sets occurredAt timestamp
    }

    public function eventName(): string { return 'driver.went_online'; }

    public function toArray(): array
    {
        return [
            'driver_id'   => (string) $this->driverId,
            'lat'         => $this->location->coordinates->latitude,
            'lng'         => $this->location->coordinates->longitude,
            'recorded_at' => $this->location->recordedAt->format(DATE_ATOM),
        ];
    }
}

// Domain Events in your project:
// Driver: DriverWentOnline · DriverWentOffline · DriverLocationUpdated
// Merchant: MerchantRegistered · MerchantKycApproved · MerchantKycRejectedDriver/Domain/Events/DriverWentOnline.php
✅ The power of Domain Events

Want to send a welcome email when a merchant is approved? Add a Listener to MerchantKycApproved. Want to update a live map when a driver goes online? Add a Listener to DriverWentOnline. None of these require changing the Entity or the Service. The Domain is not polluted with notification logic.

Domain Layer
Repository interfaces

A Repository interface is a contract. It says: "I need to be able to find and save Drivers." The Domain writes the interface. The Infrastructure implements it using Eloquent. The Domain never sees how it's implemented.

📚 The Librarian Analogy

You ask the librarian: "Get me the book with ISBN 978-0134757599." You don't care if she searches a card file, a computer system, or a remote warehouse. You just care that she returns the book. The Repository interface is your request. The EloquentDriverRepository is the librarian's method — she uses a computer (Eloquent/MySQL) to find it.

// Driver/Domain/Repositories/DriverRepository.php
// This file is 100% pure PHP — no Laravel, no Eloquent, no SQL
interface DriverRepository
{
    public function findByUuid(Uuid $uuid): ?Driver;

    public function findByUserUuid(Uuid $userUuid): ?Driver;

    public function findById(int $id): ?Driver;

    // Save creates (INSERT) or updates (UPDATE) automatically
    public function save(Driver $driver): int;

    // Find available drivers near a GPS location — no SQL here!
    public function findAvailableNear(
        Coordinates $origin,
        float $radiusKm,
        int $vehicleTypeId,
        int $limit = 10,
    ): iterable;
}Driver/Domain/Repositories/DriverRepository.php

Notice: zero SQL. Zero mention of Eloquent. Zero mention of any database. The Domain just says "I need these capabilities." It doesn't care how they're provided.

This is called the Dependency Inversion Principle: high-level code (Domain) should not depend on low-level code (Eloquent). Both should depend on abstractions (the interface).

Domain Layer
Domain Exceptions

When a business rule is violated, the Domain throws a Domain Exception. This is different from a Laravel HTTP exception — it's a pure PHP exception that represents a business rule violation.

// Driver/Domain/Exceptions/InvalidDriverStatusTransition.php
// Thrown when someone tries an illegal status change
final class InvalidDriverStatusTransition extends DomainException
{
    public static function from(DriverStatus $current, DriverStatus $attempted): self
    {
        return new self(
            "Invalid driver status transition: {$current->value} -> {$attempted->value}"
            // Example: "Invalid driver status transition: busy -> online"
        );
    }
}

// Merchant/Domain/Exceptions/InvalidKycTransitionException.php
final class InvalidKycTransitionException extends DomainException
{
    public static function from(KycStatus $current, KycStatus $attempted): self
    {
        return new self(
            "Invalid KYC transition: {$current->value} -> {$attempted->value}"
            // Example: "Invalid KYC transition: pending -> approved"
        );
    }
}Domain/Exceptions/

Your global Laravel exception handler catches DomainException and converts it to a proper HTTP 422 or 409 response. The Domain doesn't know or care about HTTP — it just throws the right exception with a clear message.

Application Layer
Commands & Queries (CQRS)

CQRS stands for Command Query Responsibility Segregation. Simple idea: actions that change data (Commands) and actions that read data (Queries) are kept completely separate.

📬 Post Office Analogy

A Command is a filled-in form you hand to the post office: "I want to register as a driver." It carries all the information needed. The form itself does nothing — it's just data. A Query is a question: "What is Ahmed's current status?" Again, just data — no logic. The Handler is the post office clerk who takes the form and does the actual work.

Commands (writes)

Change state. Have side effects. Return nothing or a result DTO.

RegisterDriverCommand GoOnlineCommand GoOfflineCommand UpdateDriverLocationCommand RegisterMerchantCommand ApproveMerchantKycCommand RejectMerchantKycCommand

Queries (reads)

Read state only. No side effects. Always return data.

GetDriverProfileQuery ListNearbyDriversQuery GetMerchantQuery ListPendingMerchantsQuery

What is a Command?

A Command is a plain PHP object — a "data bag" with no logic. It carries the intent from the Controller to the Handler.

// Driver/Application/Commands/RegisterDriverCommand.php
// Just data. Zero logic. Immutable (readonly properties).
final class RegisterDriverCommand
{
    public function __construct(
        public readonly string $userUuid,        // who's registering
        public readonly int    $vehicleTypeId,  // car, motorcycle, bicycle
        public readonly string $fullName,
        public readonly string $licenseNo,
        public readonly string $vehiclePlate,
    ) {}
    // No methods. No logic. Just carries the data.
}

// Driver/Application/Commands/UpdateDriverLocationCommand.php
// Notice: nullable optional telemetry fields
final class UpdateDriverLocationCommand
{
    public function __construct(
        public readonly Uuid $driverUuid,
        public readonly Coordinates $location,
        public readonly ?float $heading = null,
        public readonly ?float $speedKmh = null,
        public readonly ?int   $batteryPct = null,
        public readonly ?float $accuracyMeters = null,
    ) {}
}Driver/Application/Commands/

What is a Query?

// Driver/Application/Queries/GetDriverProfileQuery.php
final class GetDriverProfileQuery
{
    public function __construct(
        public readonly Uuid $userUuid, // "get the profile for user with this UUID"
    ) {}
}

// Driver/Application/Queries/ListNearbyDriversQuery.php
final class ListNearbyDriversQuery
{
    public function __construct(
        public readonly Coordinates $origin,
        public readonly float $radiusKm,
        public readonly int $vehicleTypeId,
        public readonly int $limit = 20,
    ) {}
}Driver/Application/Queries/
💡 Why separate Commands and Queries?

Write paths (Commands) often need transactions, event dispatching, and strict validation. Read paths (Queries) often need to be optimized for speed — sometimes they bypass the Domain entirely and query the database directly. Separating them lets you optimize each path independently. At large scale, Commands and Queries can even run on separate databases (write DB vs read replicas).

Application Layer
Handlers

A Handler receives a Command or Query and delegates to the appropriate Service. Handlers are thin — their only job is to unpack the Command and call the Service.

// Driver/Application/Handlers/RegisterDriverHandler.php
// Thin — unpacks Command, calls Service, converts result to DTO
final class RegisterDriverHandler
{
    public function __construct(
        private readonly RegisterDriverService $service
    ) {}

    public function handle(RegisterDriverCommand $cmd): DriverProfileDto
    {
        // 1. Delegate to the Service
        $driver = $this->service->execute([
            'user_uuid'       => $cmd->userUuid,
            'vehicle_type_id' => $cmd->vehicleTypeId,
            'full_name'       => $cmd->fullName,
            'license_no'      => $cmd->licenseNo,
            'vehicle_plate'   => $cmd->vehiclePlate,
        ]);

        // 2. Convert Domain Entity to DTO (safe for presentation)
        return DriverProfileDto::fromAggregate($driver);
    }
}

// Driver/Application/Handlers/GoOnlineHandler.php
// Even thinner — just unpack and pass to Service
final class GoOnlineHandler
{
    public function __construct(
        private readonly UpdateDriverStatusService $service
    ) {}

    public function handle(GoOnlineCommand $cmd): void
    {
        $this->service->goOnline(
            $cmd->driverUuid,
            $cmd->location,
            $cmd->heading,
            $cmd->speedKmh,
            $cmd->batteryPct,
        );
    }
}

// Merchant/Application/Handlers/ApproveMerchantKycHandler.php
final class ApproveMerchantKycHandler
{
    public function __construct(
        private readonly ApproveMerchantKycService $service
    ) {}

    public function handle(ApproveMerchantKycCommand $cmd): void
    {
        $this->service->execute($cmd->merchantUuid);
    }
}Application/Handlers/

Notice how thin the Handlers are. They just connect the Command to the Service. If you're using a message bus (like Laravel's command bus or Tactician), you might not even need explicit Handler classes — the bus routes Commands to handlers automatically. Your project uses explicit Handler injection for clarity.

Application Layer
Application Services

Application Services are the use-case executors. They orchestrate the work: load from repository, call entity methods, save back, dispatch events. They do NOT contain business rules — those live in the Domain.

RegisterDriverService — the complete example

// Driver/Application/Services/RegisterDriverService.php
final class RegisterDriverService
{
    public function __construct(
        private readonly DriverRepository $repository,  // interface!
        private readonly DomainEventBus $eventBus,
    ) {}

    public function execute(array $payload): Driver
    {
        // Wrap in DB transaction — if anything fails, rollback everything
        return DB::transaction(function () use ($payload) {

            // STEP 1: Create Domain Entity (Domain layer handles defaults and rules)
            $driver = Driver::register(
                userId: new Uuid($payload['user_uuid']),
                vehicleTypeId: (int) $payload['vehicle_type_id'],
                fullName: $payload['full_name'],
                licenseNo: $payload['license_no'],
                vehiclePlate: $payload['vehicle_plate'],
            );
            // At this point: $driver.status = Offline, $driver.id = new UUID
            //                No events recorded yet (Driver::register doesn't record events)

            // STEP 2: Persist to database via Repository (Infrastructure handles SQL)
            $this->repository->save($driver);

            // STEP 3: Dispatch any domain events the entity recorded
            $this->eventBus->dispatchAll($driver->pullDomainEvents());
            // Listeners react: welcome email, onboarding tasks, analytics, etc.

            return $driver; // Handler will convert this to DTO
        });
    }
}Driver/Application/Services/RegisterDriverService.php

UpdateDriverStatusService — going online

// Driver/Application/Services/UpdateDriverStatusService.php
// NOTE: "going online" REQUIRES a location — driver shows immediately on map
final class UpdateDriverStatusService
{
    public function goOnline(Uuid $driverUuid, Coordinates $coordinates, /* ... */): void
    {
        DB::transaction(function () use ($driverUuid, $coordinates, /* ... */) {

            // 1. Load the Driver Entity from DB
            $driver = $this->repository->findByUuid($driverUuid)
                ?? throw new RuntimeException("Driver not found: {$driverUuid}");

            // 2. Call Domain method (Domain enforces the rules)
            //    If driver is Busy, this throws InvalidDriverStatusTransition
            $driver->goOnline(new DriverLocation(
                coordinates: $coordinates,
                recordedAt: new DateTimeImmutable(),
                heading: $heading,
                speedKmh: $speedKmh,
            ));

            // 3. Save updated entity back to DB
            $this->repository->save($driver);

            // 4. Dispatch events (DriverWentOnline fires here)
            $this->eventBus->dispatchAll($driver->pullDomainEvents());
        });
    }
}
Driver/Application/Services/UpdateDriverStatusService.php

ApproveMerchantKycService

// Merchant/Application/Services/ApproveMerchantKycService.php
final class ApproveMerchantKycService
{
    public function execute(Uuid $merchantUuid): void
    {
        DB::transaction(function () use ($merchantUuid) {

            $merchant = $this->repository->findByUuid($merchantUuid);
            if (! $merchant) {
                throw new RuntimeException("Merchant not found: {$merchantUuid}");
            }

            // Domain enforces: KycStatus must be Submitted to approve
            // If it's Pending → throws InvalidKycTransitionException
            $merchant->approveKyc();

            $this->repository->save($merchant);

            // Fires MerchantKycApproved event
            // → Listener sends "Welcome! You can now create deliveries" email
            $this->eventBus->dispatchAll($merchant->pullDomainEvents());
        });
    }
}Merchant/Application/Services/ApproveMerchantKycService.php
💡 Pattern: Load → Act → Save → Dispatch

Every Application Service follows this exact four-step pattern: 1) Load the Entity from Repository. 2) Act — call the Entity method (which enforces business rules). 3) Save the updated Entity back via Repository. 4) Dispatch any Domain Events the Entity recorded. Memorize this pattern. It applies to every write operation in DDD.

Application Layer
DTOs — Data Transfer Objects

A DTO is a safe, flat container for data that leaves the Application layer. It's a "photocopy" of your Entity — it has the information but none of the Entity's behaviour. Controllers always receive DTOs, never raw Domain Entities.

📄 The Medical Report Analogy

A doctor (Entity) has your complete medical history and the ability to prescribe medication. When you ask for a summary, the receptionist gives you a printed report (DTO) — just the relevant data, nicely formatted, with no prescribing power. The report is safe to hand to anyone. The Entity is not.

// Driver/Application/DTOs/DriverProfileDto.php
final class DriverProfileDto
{
    public function __construct(
        public readonly string $uuid,
        public readonly string $userUuid,
        public readonly int    $vehicleTypeId,
        public readonly string $fullName,
        public readonly string $licenseNo,
        public readonly string $vehiclePlate,
        public readonly string $status,          // string, not DriverStatus enum
        public readonly ?array  $lastLocation,    // flat array, not DriverLocation object
        public readonly float  $rating,
        public readonly int    $totalDeliveries,
        public readonly int    $failedDeliveries,
        public readonly float  $successRate,     // pre-computed!
    ) {}

    // ═══ Factory method: Entity → DTO ═══
    // This is the ONLY place where Domain objects are unwrapped
    public static function fromAggregate(Driver $d): self
    {
        $loc = $d->lastLocation();
        return new self(
            uuid:             (string) $d->id,
            userUuid:         (string) $d->userId,
            vehicleTypeId:    $d->vehicleTypeId,
            fullName:         $d->fullName(),
            licenseNo:        $d->licenseNo(),
            vehiclePlate:     $d->vehiclePlate(),
            status:           $d->status()->value,    // enum → string
            lastLocation:     $loc ? [
                'lat'         => $loc->coordinates->latitude,
                'lng'         => $loc->coordinates->longitude,
                'heading'     => $loc->heading,
                'speed_kmh'   => $loc->speedKmh,
                'recorded_at' => $loc->recordedAt->format(DATE_ATOM),
            ] : null,
            rating:           $d->rating(),
            totalDeliveries:  $d->totalDeliveries(),
            failedDeliveries: $d->failedDeliveries(),
            successRate:      round($d->successRate(), 4), // computed here
        );
    }

    // For JSON API response
    public function toArray(): array { return [...]; }
}Driver/Application/DTOs/DriverProfileDto.php

Why not just return the Entity directly from the Controller? Because the Entity has private state, business methods, and is coupled to your Domain model. If you change an internal field on the Entity (say, rename $licenseNo to $licenceNumber), your API response would change too, breaking all clients. The DTO is a stable API contract. The Entity can evolve internally without breaking the API.

Infrastructure Layer
Eloquent Models

In DDD, Eloquent Models are only used for database access. They are NOT the same as your Domain Entities. They are dumb data mappers — they know about columns and relationships, nothing about business rules.

💡 The crucial distinction

In normal MVC, your Eloquent Model IS your business object. You might add methods like isAvailable() on your Driver Eloquent model. In DDD, the Eloquent Model is ONLY a DB adapter. All logic lives in Domain/Entities/Driver.php (pure PHP). The Eloquent model is just a way to read/write the drivers table.

// Driver/Infrastructure/Persistence/Eloquent/Models/DriverModel.php
// Notice: no business methods! Just column definitions and relationships.
final class DriverModel extends Model
{
    use SoftDeletes;

    protected $table = 'drivers';
    protected $guarded = [];       // allow mass assignment (fillable handled by Repository)

    protected $casts = [
        'license_expiry'     => 'date',
        'current_lat'        => 'float',
        'current_lng'        => 'float',
        'heading'            => 'float',
        'speed_kmh'          => 'float',
        'rating'             => 'float',
        'location_updated_at'=> 'datetime',
    ];

    // Auto-generate UUID on creation (if not provided)
    protected static function booted(): void
    {
        static::creating(function (self $d): void {
            if (empty($d->uuid)) {
                $d->uuid = (string) Str::uuid();
            }
        });
    }

    // Relationships — fine to have here
    public function user(): BelongsTo
    {
        return $this->belongsTo(UserModel::class, 'user_id');
    }
    // No isAvailable(), no canGoOnline(), no goOnline() here!
    // Those ONLY live in Domain/Entities/Driver.php
}Driver/Infrastructure/Persistence/Eloquent/Models/DriverModel.php
Infrastructure Layer
Eloquent Repositories

The Eloquent Repository implements the Domain Repository interface using Eloquent. It does two critical things: converts Domain Entities to DB rows when saving, and converts DB rows back to Domain Entities when loading.

The hydration cycle: Entity ↔ Database

// Driver/Infrastructure/Persistence/Eloquent/Repositories/EloquentDriverRepository.php
final class EloquentDriverRepository implements DriverRepository
{
    // ═══ READ: Database row → Domain Entity ═══
    public function findByUuid(Uuid $uuid): ?Driver
    {
        // Step 1: Eloquent query (returns DriverModel or null)
        $model = DriverModel::query()
            ->where('uuid', $uuid->value)
            ->first();

        // Step 2: Convert to Domain Entity (or return null)
        return $model ? $this->toDomain($model) : null;
    }

    // ═══ WRITE: Domain Entity → Database row ═══
    public function save(Driver $driver): int
    {
        // Look up the owner by UUID → get their integer ID
        $userId = UserModel::query()
            ->where('uuid', $driver->userId->value)
            ->value('id');

        // Find existing DB row or create new one
        $model = DriverModel::query()
            ->where('uuid', $driver->id->value)
            ->first() ?? new DriverModel();

        $loc = $driver->lastLocation();

        // Flatten the Domain Entity into DB columns
        $model->fill([
            'uuid'               => $driver->id->value,
            'user_id'            => $userId,
            'vehicle_type_id'    => $driver->vehicleTypeId,
            'full_name'          => $driver->fullName(),
            'license_no'         => $driver->licenseNo(),
            'vehicle_plate'      => $driver->vehiclePlate(),
            'status'             => $driver->status()->value,      // enum → string
            'current_lat'        => $loc?->coordinates->latitude,  // VO → float
            'current_lng'        => $loc?->coordinates->longitude,
            'location_updated_at'=> $loc?->recordedAt,
            'heading'            => $loc?->heading,
            'speed_kmh'          => $loc?->speedKmh,
            'rating'             => $driver->rating(),
            'total_deliveries'   => $driver->totalDeliveries(),
            'failed_deliveries'  => $driver->failedDeliveries(),
        ]);
        $model->save(); // INSERT or UPDATE

        return (int) $model->id;
    }
EloquentDriverRepository.php (partial)
    // ═══ The toDomain() method — the most important method in Infrastructure ═══
    // This is "hydration": rebuilding a rich Domain Entity from a flat DB row
    private function toDomain(DriverModel $m): Driver
    {
        $userUuid = UserModel::query()->where('id', $m->user_id)->value('uuid');

        // Rebuild DriverLocation Value Object from separate columns
        $location = null;
        if ($m->current_lat !== null && $m->current_lng !== null) {
            $location = new DriverLocation(
                coordinates: new Coordinates(
                    (float) $m->current_lat,
                    (float) $m->current_lng
                ),
                recordedAt: new DateTimeImmutable($m->location_updated_at->toIso8601String()),
                heading:    $m->heading !== null ? (float) $m->heading : null,
                speedKmh:   $m->speed_kmh !== null ? (float) $m->speed_kmh : null,
            );
        }

        // Call Driver::hydrate() — restores an existing Driver from stored state
        // Note: this does NOT fire Domain Events (Driver::register() doesn't either)
        return Driver::hydrate(
            id:              new Uuid((string) $m->uuid),
            userId:          new Uuid((string) $userUuid),
            vehicleTypeId:   (int) $m->vehicle_type_id,
            fullName:        (string) $m->full_name,
            licenseNo:       (string) $m->license_no,
            vehiclePlate:    (string) $m->vehicle_plate,
            status:          DriverStatus::from((string) $m->status), // string → enum
            lastLocation:    $location,
            rating:          (float) $m->rating,
            totalDeliveries: (int) $m->total_deliveries,
            failedDeliveries:(int) $m->failed_deliveries,
        );
    }
}EloquentDriverRepository.php — toDomain()

findAvailableNear — geospatial dispatch query

// Finding nearby available drivers — a real production-quality implementation
public function findAvailableNear(
    Coordinates $origin,
    float $radiusKm,
    int $vehicleTypeId,
    int $limit = 10,
): iterable {
    // Step 1: Bounding-box pre-filter using DB index (FAST — uses btree index)
    // Converts radius in km to lat/lng deltas
    $latDelta = $radiusKm / 110.574;
    $lngDelta = $radiusKm / (111.320 * cos(deg2rad($origin->latitude)));

    $rows = DriverModel::query()
        ->where('status', DriverStatus::Online->value)    // only online drivers
        ->where('vehicle_type_id', $vehicleTypeId)         // right vehicle type
        ->whereBetween('current_lat', [
            $origin->latitude - $latDelta,
            $origin->latitude + $latDelta
        ])
        ->whereBetween('current_lng', [
            $origin->longitude - $lngDelta,
            $origin->longitude + $lngDelta
        ])
        ->whereNotNull('current_lat')
        ->limit($limit * 3)   // over-fetch then refine in PHP
        ->get();

    // Step 2: Haversine distance calculation (PRECISE) in PHP
    // The bounding box may include corners that are actually farther than $radiusKm
    $withDistance = [];
    foreach ($rows as $m) {
        $coords = new Coordinates((float) $m->current_lat, (float) $m->current_lng);
        $dist = $origin->distanceKmTo($coords);
        if ($dist <= $radiusKm) {
            $withDistance[] = ['driver' => $this->toDomain($m), 'distance' => $dist];
        }
    }

    // Sort nearest first
    usort($withDistance, fn($a, $b) => $a['distance'] <=> $b['distance']);

    return array_map(fn($r) => $r['driver'], array_slice($withDistance, 0, $limit));
}EloquentDriverRepository — findAvailableNear()
Infrastructure Layer
Service Providers — the wiring layer

The Service Provider is Laravel's dependency injection configuration. It tells Laravel: "When someone asks for the DriverRepository interface, give them EloquentDriverRepository." It also registers routes.

// Driver/Infrastructure/Providers/DriverServiceProvider.php
final class DriverServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // ══ The key binding ══
        // "Whenever someone type-hints DriverRepository, inject EloquentDriverRepository"
        // This is the Dependency Inversion Principle in action:
        //   - Application Services depend on the INTERFACE (DriverRepository)
        //   - Infrastructure provides the IMPLEMENTATION (EloquentDriverRepository)
        //   - They never know about each other directly
        $this->app->bind(
            DriverRepository::class,            // interface (Domain layer)
            EloquentDriverRepository::class,   // implementation (Infrastructure layer)
        );
        // To swap MySQL for Redis: just change EloquentDriverRepository to RedisDriverRepository
        // Nothing else in the codebase changes!
    }

    public function boot(): void
    {
        // Register this module's routes with the API prefix and middleware
        Route::middleware('api')
            ->prefix('api/v1')
            ->group(base_path('src/Driver/Presentation/Routes/api.php'));
    }
}Driver/Infrastructure/Providers/DriverServiceProvider.php
🔌 The Power Socket Analogy

Your laptop (Application Service) needs power (DriverRepository). It doesn't know if power comes from the wall socket, a generator, or a battery. It just plugs into the socket (interface). The Service Provider is the wiring in the wall — it decides where the power actually comes from (Eloquent). To switch to a different power source (Redis), you just change the wiring in the Service Provider.

Presentation Layer
Controllers

Controllers are the entry point for HTTP requests. In DDD, they are deliberately thin: validate raw input, create a Command or Query, call the Handler, and return a formatted response. No business logic. No database queries. No email sending.

// Driver/Presentation/Http/Controllers/DriverController.php (key methods)
final class DriverController extends Controller
{
    // ── Dependency Injection: Laravel injects all these handlers ──
    public function __construct(
        private readonly RegisterDriverHandler         $registerHandler,
        private readonly GoOnlineHandler              $onlineHandler,
        private readonly GoOfflineHandler             $offlineHandler,
        private readonly UpdateDriverLocationHandler   $locationHandler,
        private readonly GetDriverProfileHandler       $profileHandler,
        private readonly ListNearbyDriversHandler      $nearbyHandler,
    ) {}

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

        // Step 2: Create Command (intent object) and send to Handler
        $dto = $this->registerHandler->handle(new RegisterDriverCommand(
            userUuid:      $v['user_uuid'],
            vehicleTypeId: (int) $v['vehicle_type_id'],
            fullName:      $v['full_name'],
            licenseNo:     $v['license_no'],
            vehiclePlate:  $v['vehicle_plate'],
        ));

        // Step 3: Return formatted JSON — 201 Created
        return response()->json(['data' => $dto->toArray()], 201);
    }

    // ── POST /api/v1/drivers/me/ping — high-frequency GPS update ──
    public function ping(Request $request): JsonResponse
    {
        $v = $request->validate([
            'lat'        => ['required', 'numeric', 'between:-90,90'],
            'lng'        => ['required', 'numeric', 'between:-180,180'],
            'heading'    => ['nullable', 'numeric', 'between:0,360'],
            'speed_kmh'  => ['nullable', 'numeric', 'max:200'],
            'battery_pct'=> ['nullable', 'integer', 'between:0,100'],
        ]);

        $self = $this->resolveSelf($request); // gets current driver's DTO

        $this->locationHandler->handle(new UpdateDriverLocationCommand(
            driverUuid: $self->uuid,
            location: new Coordinates((float) $v['lat'], (float) $v['lng']),
            heading:   isset($v['heading']) ? (float) $v['heading'] : null,
            speedKmh:  isset($v['speed_kmh']) ? (float) $v['speed_kmh'] : null,
        ));

        // 202 Accepted: we received it, processing async (fast mobile response)
        return response()->json(['data' => ['accepted' => true]], 202);
    }
}Driver/Presentation/Http/Controllers/DriverController.php

Notice the Controller has no business logic. It doesn't know what "going online" means for the business. It doesn't know what rules apply. It just validates the HTTP format, creates a Command, and passes it on.

Presentation Layer
Routes

Routes are defined within each module and registered by the Service Provider. This keeps routes co-located with their module's code.

// Driver/Presentation/Routes/api.php
// All routes require authentication (auth:sanctum = JWT/token auth)
Route::middleware('auth:sanctum')->group(function () {

    // Admin-only: register a new driver (requires admin role)
    Route::post('/drivers', [DriverController::class, 'store'])
        ->middleware('role:admin|super-admin');

    // Admin live-map: find drivers near a location
    Route::get('/admin/drivers/nearby', [DriverController::class, 'nearby'])
        ->middleware('role:admin|super-admin');

    // Driver self-service routes (any authenticated driver)
    Route::prefix('drivers/me')->group(function () {
        Route::get('/',         [DriverController::class, 'me']);       // GET my profile
        Route::post('/online',  [DriverController::class, 'goOnline']); // go available
        Route::post('/offline', [DriverController::class, 'goOffline']); // go unavailable
        Route::post('/ping',    [DriverController::class, 'ping']);     // GPS update (every 5-15s)
    });
});

// Merchant/Presentation/Routes/api.php
Route::middleware('auth:sanctum')->group(function () {
    // Admin: register, approve, reject, list pending
    Route::post('/merchants', [MerchantController::class, 'store']);
    Route::get('/merchants/{uuid}', [MerchantController::class, 'show']);
    Route::put('/merchants/{uuid}', [MerchantController::class, 'update']);
    Route::post('/merchants/{uuid}/kyc/approve', [MerchantController::class, 'approveKyc']);
    Route::post('/merchants/{uuid}/kyc/reject',  [MerchantController::class, 'rejectKyc']);
    Route::get('/admin/merchants/pending', [MerchantController::class, 'pending'])
        ->middleware('role:admin|super-admin');
});Presentation/Routes/api.php
Walkthroughs
Full request lifecycle

Let's trace a single API request — registering a new driver — from the moment it hits the server to the moment the response goes back. Every layer, every file, in order.

Request: POST /api/v1/drivers
{
  "user_uuid": "550e8400-e29b-41d4-a716-446655440000",
  "vehicle_type_id": 2,
  "full_name": "Ahmed Al-Rashidi",
  "license_no": "KW-2024-9876",
  "vehicle_plate": "5KK 555"
}
1
Infrastructure DriverServiceProvider boots
At application startup, DriverServiceProvider registered two things: (a) the route POST /api/v1/drivers → DriverController::store() and (b) the binding DriverRepository → EloquentDriverRepository.
2
Presentation Laravel router matches the route
The HTTP request hits Laravel. The router matches POST /api/v1/drivers to DriverController::store(). Laravel's auth middleware checks the Bearer token via auth:sanctum. The role:admin middleware checks the user has admin access.
3
Presentation DriverController::store() validates raw input
$request->validate([...]) checks: is user_uuid a valid UUID format? Is vehicle_type_id an integer that exists in the vehicle_types table? Is license_no unique in the drivers table? If any check fails → Laravel auto-returns 422 with error details. No code below this line runs.
$v = $request->validate([
    'user_uuid'      => ['required', 'uuid'],
    'license_no'     => ['required', 'string', 'unique:drivers,license_no'],
    // ...
]);DriverController.php
4
Presentation Controller creates Command and calls Handler
The Controller packages the validated data into a RegisterDriverCommand (a plain data object, no logic) and calls $this->registerHandler->handle($command). The Controller's job is now done — it waits for the result.
$dto = $this->registerHandler->handle(new RegisterDriverCommand(
    userUuid: $v['user_uuid'],
    fullName:  $v['full_name'],
    // ...
));DriverController.php
5
Application RegisterDriverHandler delegates to Service
RegisterDriverHandler::handle() unpacks the Command and calls RegisterDriverService::execute(). The Handler converts the result (a Driver Entity) into a DriverProfileDto and returns it to the Controller.
6
Application Service begins a DB transaction
DB::transaction(function() {...}) starts. If anything inside fails (exception), the entire transaction rolls back. This ensures the database never gets into an inconsistent state.
7
Domain Driver::register() creates the Entity
The Service calls Driver::register(userId, vehicleTypeId, fullName, licenseNo, vehiclePlate). The Entity's factory method generates a new UUID, sets status = Offline, initializes rating at 0.0, and creates the Driver object. No events are recorded at this stage.
$driver = Driver::register(
    userId: new Uuid($payload['user_uuid']),
    status: DriverStatus::Offline,    // always starts offline!
    // ...
);RegisterDriverService.php
8
Infrastructure EloquentDriverRepository::save() persists to DB
$this->repository->save($driver) calls EloquentDriverRepository::save(). This: (a) looks up the user's integer ID from their UUID, (b) flattens the Driver Entity into column values, (c) does an INSERT into the drivers table. MySQL assigns the auto-increment id.
9
Application Domain Events dispatched
$this->eventBus->dispatchAll($driver->pullDomainEvents()). Any events recorded on the Entity are dispatched. In Driver::register(), no events are recorded (registration doesn't fire events in your current code). If listeners existed, they'd react here.
10
Application Transaction commits. Entity returned.
The DB::transaction closure returns the $driver Entity. If we reached here without exceptions, the DB commit succeeds. The Service returns the Entity to the Handler.
11
Application Handler converts Entity → DTO
DriverProfileDto::fromAggregate($driver) creates a flat, safe DTO from the rich Domain Entity. The DTO has strings and arrays — no Domain objects that the Controller shouldn't know about.
12
Presentation Controller returns JSON response
return response()->json(['data' => $dto->toArray()], 201). The 201 Created response with the driver's profile JSON is sent back to the API client.
{
  "data": {
    "uuid": "7c3f5e90-...",
    "full_name": "Ahmed Al-Rashidi",
    "status": "offline",
    "rating": 0,
    "success_rate": 0,
    "last_location": null
  }
}Response: 201 Created
Walkthroughs
Merchant walkthrough — from registration to first delivery

"Al Fanar Restaurant" wants to use your delivery app. Here's the complete journey through the code.

Step 1: Register the merchant

// POST /api/v1/merchants
{
  "owner_user_uuid": "...",
  "business_name": "Al Fanar Restaurant",
  "contact_email": "ops@alfanar.kw",
  "contact_phone": "+965-2223-3456",
  "billing_mode": "postpaid",
  "credit_limit": 500.00,
  "currency": "KWD",
  "default_address": {
    "line1": "Arabian Gulf Street", "city": "Kuwait City",
    "country": "KW", "lat": 29.3759, "lng": 47.9774
  }
}

// MerchantController validates → RegisterMerchantCommand → RegisterMerchantHandler
// → RegisterMerchantService → Merchant::register()
// Entity state: kycStatus = KycStatus::Pending, isActive = true
// Event fired: MerchantRegistered (welcome email Listener reacts here)POST /api/v1/merchants

Step 2: Submit KYC documents

// Merchant uploads business license, CR number, etc.
// PATCH /api/v1/merchants/{uuid}/kyc/submit

// Merchant entity enforces:
public function submitKyc(): void
{
    if ($this->kycStatus !== KycStatus::Pending) {
        throw InvalidKycTransitionException::from($this->kycStatus, KycStatus::Submitted);
    }
    $this->kycStatus = KycStatus::Submitted;
    // Now admin can see this merchant in the pending review queue
}Merchant/Domain/Entities/Merchant.php

Step 3: Admin reviews and approves

// GET /api/v1/admin/merchants/pending
// Returns list of merchants with kycStatus = 'submitted'
// Admin reviews the documents, then:

// POST /api/v1/merchants/{uuid}/kyc/approve
// → ApproveMerchantKycHandler → ApproveMerchantKycService

// In the Service:
$merchant->approveKyc();     // enforces: must be Submitted
$this->repository->save($merchant);  // kycStatus = 'approved' saved to DB
$this->eventBus->dispatchAll($merchant->pullDomainEvents());
// Event: MerchantKycApproved fired
// Listener: sends "Congratulations! You can now create deliveries" email
// Listener: unlocks the merchant dashboard in the frontendApproveMerchantKycService.php
KYC Pending
→ submitKyc()
→ approveKyc()
KYC Approved ✓
→ canCreateDeliveries()
Can Place Orders ✓

Step 4: Merchant creates a delivery order

// In the Delivery context (a separate module), before creating an order:
// The Delivery context loads the Merchant and checks eligibility

$merchant = $merchantRepository->findByUuid($merchantId);

if (! $merchant->canCreateDeliveries()) {
    throw new MerchantNotEligibleException(
        "Merchant cannot create deliveries. KYC status: {$merchant->kycStatus()->value}"
    );
}
// If KYC is Pending → "Merchant cannot create deliveries. KYC status: pending"
// If KYC is Rejected → "Merchant cannot create deliveries. KYC status: rejected"
// If account inactive → "Merchant cannot create deliveries. KYC status: approved" (but isActive=false)Delivery module (concept)
Walkthroughs
Driver walkthrough — from registration to first delivery

Ahmed Al-Rashidi is a motorcycle courier. Here's his complete journey through the code.

Ahmed's driver status flow

Offline
→ goOnline()
Online
→ markBusy()
Busy
→ recordDeliverySuccess()
Online

Going online — the GPS requirement

// POST /api/v1/drivers/me/online
// Ahmed opens the app, taps "Go Online"
// NOTE: location is REQUIRED — driver shows on live map immediately
{
  "lat": 29.3759, "lng": 47.9774,    // Kuwait City
  "heading": 90.5,
  "speed_kmh": 0,
  "battery_pct": 87
}

// GoOnlineHandler → UpdateDriverStatusService.goOnline()
$driver->goOnline(new DriverLocation(
    coordinates: new Coordinates(29.3759, 47.9774),
    recordedAt: new DateTimeImmutable(),
    heading: 90.5,
    speedKmh: 0,
    batteryPct: 87,
));
// Inside goOnline(): status Offline → Online ✓ (canGoOnline() returns true)
// Event recorded: DriverWentOnline(driverId, location)
// After save: event dispatched → dispatchers can now find AhmedPOST /api/v1/drivers/me/online

GPS pings — every 5–15 seconds

// POST /api/v1/drivers/me/ping (Ahmed is driving around)
// High-frequency endpoint — must respond fast (202 Accepted)
{
  "lat": 29.3812, "lng": 47.9801,
  "heading": 45.0, "speed_kmh": 32.5, "battery_pct": 84
}

// UpdateDriverLocationService (deliberately thin!):
// - Updates driver's lastLocation in DB
// - Fires DriverLocationUpdated event
// - Returns immediately — no waiting for listeners
// 
// The Tracking context SUBSCRIBES to DriverLocationUpdated and stores GPS history.
// This service doesn't do that — not its responsibility!
// This keeps ping response time under 50ms even at high volume.POST /api/v1/drivers/me/ping

Dispatch finds Ahmed

// GET /api/v1/admin/drivers/nearby?lat=29.37&lng=47.97&radius_km=5&vehicle_type_id=2
// Al Fanar Restaurant needs a motorcycle courier nearby

// EloquentDriverRepository.findAvailableNear():
// 1. Bounding-box SQL query (fast, uses index)
//    WHERE status = 'online' AND vehicle_type_id = 2
//    AND current_lat BETWEEN 29.34 AND 29.40
//    AND current_lng BETWEEN 47.93 AND 48.01
// 2. Haversine calculation in PHP (precise distance)
// 3. Sort nearest first → Ahmed at 1.2km appears first
// 4. Dispatch system sends Ahmed the delivery offer

// When Ahmed accepts:
$driver->markBusy();   // status: Online → Busy (no more offers)Dispatch flow
Where things go
Authentication in DDD

Authentication (proving who you are) lives outside the DDD modules, in Laravel's middleware layer. The Domain never knows about JWT tokens or session cookies.

Authentication (who are you?)
Laravel auth:sanctum middleware — applied at the route level. Completely outside the DDD layers.
Authorization (are you allowed?)
Route middleware: role:admin|super-admin. Or Domain Entity method: merchant->canCreateDeliveries(). Never in Services or Repositories.
Current user identity
Controller uses $request->user() to get the authenticated user, then maps to Domain entity via UUID: findByUserUuid(new Uuid($user->uuid)).
// How auth connects to Domain in your controller:
private function resolveSelf(Request $request): object
{
    $user = $request->user();                                    // Laravel auth
    abort_unless($user && $user->user_type === 'driver', 403, 'Not a driver account');

    $dto = $this->profileHandler->handle(                        // DDD lookup
        new GetDriverProfileQuery(userUuid: new Uuid((string) $user->uuid))
    );
    abort_unless($dto, 404, 'Driver profile not found');

    return (object) ['uuid' => new Uuid($dto->uuid)];
}
// Pattern: Auth gives us the User. DDD gives us the Domain Entity (Driver/Merchant).
// The bridge is the UUID — both systems share it.DriverController.php — auth bridging
Where things go
Validation in DDD — two kinds

There are two kinds of validation in DDD, and they live in different places.

HTTP Input Validation

Lives in the Presentation layer (Controller). Validates raw HTTP request format: required fields, data types, max lengths, database unique constraints. Returns 422 automatically if invalid.

Examples: Is lat a number? Is email a valid format? Is license_no unique?

$request->validate([
  'lat' => ['required', 'numeric', 'between:-90,90'],
  'license_no' => ['unique:drivers,license_no'],
]);
Business Rule Validation

Lives in the Domain layer (Entity methods). Validates business constraints: can a driver go online? can a merchant create deliveries? Is this state transition valid?

Examples: Cannot go online while busy. Cannot approve KYC that wasn't submitted.

// In Entity:
if (!$this->status->canGoOnline()) {
  throw InvalidDriverStatusTransition::...;
}
❌ Common Mistake

Putting business rules in Controller validation: 'status' => ['in:offline,on_break']. This means the rule is only enforced from HTTP. If a queue job calls the service directly, the rule is bypassed. Business rules must live in the Domain Entity — they protect the invariant everywhere.

Where things go
Where business logic goes

The golden rule: if it's a business question, it belongs in the Domain.

"Can this driver go online?"
Domain/Entities/Driver.php → goOnline() — enforces the status transition rule.
"Can this merchant place orders?"
Domain/Entities/Merchant.php → canCreateDeliveries() — checks KYC status and active flag.
"What is this driver's success rate?"
Domain/Entities/Driver.php → successRate() — computed from stored counters.
"Is this GPS location stale?"
Domain/ValueObjects/DriverLocation.php → isStale() — belongs on the Value Object.
"Which drivers are available near X?"
Domain/Repositories/DriverRepository.php → findAvailableNear() — the Domain defines the interface; Infrastructure implements the SQL.
// DECISION TREE for where logic goes:
//
// Does it answer "is this allowed?" or "what does this mean?"
//   AND use only the Entity's own data?
//   → Domain/Entities/ (Entity method)
//
// Does it involve coordinating multiple entities or repos?
//   → Application/Services/ (Application Service)
//
// Does it involve SQL, Eloquent, or external APIs?
//   → Infrastructure/ (Repository or external service adapter)
//
// Does it format data for the HTTP response?
//   → Presentation/ (Controller, DTO, Resource)Decision tree
Where things go
DB, external APIs & notifications
Database queries
Infrastructure/Persistence/Eloquent/Repositories/ — EloquentDriverRepository, EloquentMerchantRepository. All SQL lives here.
External API calls (SMS, maps, payments)
Infrastructure/ — create an adapter class. Example: Infrastructure/Messaging/TwilioSmsAdapter.php. Implement a Domain interface. Never call external APIs from Services directly.
Email / push notifications
Application/Listeners/ — subscribe to Domain Events. Example: SendKycApprovedEmailListener listens to MerchantKycApproved. The Domain fires the event; the Listener sends the email.
Queue jobs (async work)
Infrastructure/Jobs/ — background jobs that call Application Services. Example: ProcessDriverLocationJob that calls the location service from a queue.
Caching
Infrastructure/ — cache adapters that implement Repository interfaces with a caching layer. The Domain never knows caching exists.
File storage (documents, photos)
Infrastructure/Storage/ — S3 adapter. Implement a Domain interface like DocumentStorageService.
Learn & Grow
Common beginner mistakes

These are the patterns that break DDD. Each one has been made by developers everywhere. Learning to recognize them early saves weeks of refactoring.

Mistake 1 — Importing Eloquent into the Domain
// ❌ WRONG: Domain Entity importing from Infrastructure
namespace DeliveryApp\Driver\Domain\Entities;
use Illuminate\Database\Eloquent\Model; // ❌ NEVER DO THIS
class Driver extends Model { ... }       // ❌ NEVER DO THIS

// ✅ CORRECT: Domain Entity is pure PHP
namespace DeliveryApp\Driver\Domain\Entities;
// No imports from Laravel or Eloquent here!
final class Driver extends AggregateRoot { ... } // Pure PHP

Impact: You cannot test the Domain without a database. You cannot swap databases without changing Domain code. The Eloquent model appears in your Domain — the entire separation collapses.

Mistake 2 — Querying the database from the Controller
// ❌ WRONG: Controller bypasses all layers
public function goOnline(Request $request)
{
    $driver = Driver::where('user_id', auth()->id())->first(); // ❌ Eloquent in Controller
    $driver->update(['status' => 'online']);                     // ❌ No business rules!
}

// ✅ CORRECT: Controller → Handler → Service → Repository
public function goOnline(Request $request)
{
    $this->onlineHandler->handle(new GoOnlineCommand(...)); // ✅ Delegate properly
}

Impact: The "can a busy driver go online?" rule is bypassed. A hacker could set any driver to any status directly. The Domain's protection is useless if you bypass it.

Mistake 3 — Anemic Domain Model (Entity with no behaviour)
// ❌ WRONG: Entity is just a data bag, all logic in Service
class Driver {
    public $status;  // public! Anyone can set it!
}
// Service does the logic:
// if ($driver->status === 'offline') $driver->status = 'online'; // ❌

// ✅ CORRECT: Entity owns its own behaviour
final class Driver {
    private DriverStatus $status;   // private!
    public function goOnline(...): void {
        if (!$this->status->canGoOnline()) throw ...; // ✅ rule enforced here
        $this->status = DriverStatus::Online;
    }
}

Impact: The rule must be copied in every Service/Controller that changes status. When the rule changes, you miss one copy — bug.

Mistake 4 — Returning Entities from Controllers
// ❌ WRONG: Controller returns the Domain Entity directly
return response()->json($driver);  // ❌ exposes Domain internals

// ✅ CORRECT: Always convert to DTO first
$dto = DriverProfileDto::fromAggregate($driver);
return response()->json(['data' => $dto->toArray()]);  // ✅

Impact: Renaming an internal field on the Entity changes your public API, breaking all clients. The DTO is a stable API contract. Internal Domain changes don't affect it.

Mistake 5 — Cross-module Entity imports
// ❌ WRONG: Driver module imports Merchant Entity
namespace DeliveryApp\Driver\Application\Services;
use DeliveryApp\Merchant\Domain\Entities\Merchant; // ❌ modules coupled!

// ✅ CORRECT: Modules communicate via UUIDs or Events only
// Driver module references merchant by UUID string — never imports Merchant entity
// If you need Merchant data, use Merchant's public API (repository/service),
// or subscribe to Merchant's Domain Events

Impact: Changing the Merchant module breaks the Driver module. You can no longer develop or deploy them independently.

Mistake 6 — Fat Application Services
// ❌ WRONG: Service doing too much
public function execute(...): void
{
    DB::transaction(function() {
        $driver->goOnline(...);
        $this->repository->save($driver);
        Notification::send($nearbyMerchants, ...);  // ❌ not Service's job
        Analytics::track(...);                       // ❌ not Service's job
        $this->smsService->send(...);                // ❌ not Service's job
    });
}

// ✅ CORRECT: Service only orchestrates the core use case
public function execute(...): void
{
    DB::transaction(function() {
        $driver->goOnline(...);
        $this->repository->save($driver);
        $this->eventBus->dispatchAll($driver->pullDomainEvents()); // ✅ fire event
        // Listeners handle: notifications, analytics, SMS — independently
    });
}

Impact: Adding a new side effect requires modifying the Service. One Service failure blocks all other reactions. Hard to test (8 dependencies). Use Domain Events + Listeners instead.

Learn & Grow
Best practices

Rules and principles that experienced DDD practitioners follow. These come from real projects and real pain.

🔒 Private by default

All Entity properties are private. Expose them only via getter methods. This prevents external code from bypassing business rules.

✅ One aggregate per transaction

Each DB transaction should save only ONE aggregate. Use Domain Events to coordinate changes across multiple aggregates asynchronously.

📝 Immutable Value Objects

Value Objects should use readonly properties. To "change" a VO, create a new one. This prevents accidental mutation bugs.

🎯 One use case per Service

Each Application Service handles exactly one use case: RegisterDriverService, ApproveMerchantKycService. Never make "mega services" with 20 methods.

🔌 Depend on interfaces

Services depend on DriverRepository (interface), not EloquentDriverRepository (implementation). This makes testing and swapping easy.

🧪 Test the Domain first

Unit test your Domain Entities without a database. $driver = Driver::register(...); $driver->goOnline(...) runs in under 1ms. No Laravel setup needed.

📦 Module isolation

Modules communicate via UUIDs and Domain Events only. Never import another module's Entity. Treat each module as if it could be deployed as a separate service one day.

🚫 No Eloquent in Domain

The Domain folder must be free of use Illuminate\... imports. Enforce this with an architecture test using PHPStan or pest-arch.

📌 Ubiquitous language

Use the same words as your business uses. If your business says "KYC", use KycStatus, not VerificationStatus. If they say "go online", use goOnline(), not activate().

Learn & Grow
Naming conventions

Your project uses consistent naming throughout. Follow these patterns for every new file you create.

Type Pattern Examples from your project Rule
Entity/Aggregate Noun Driver, Merchant The thing itself — no suffix
Value Object Noun or Concept DriverStatus, DriverLocation, KycStatus, BillingMode Name the concept it represents
Domain Event PastTenseVerb + Noun DriverWentOnline, MerchantKycApproved, DriverLocationUpdated Always past tense — it happened
Domain Exception Invalid + Noun + Exception InvalidDriverStatusTransition, InvalidKycTransitionException Describes the violated rule
Repository Interface Noun + Repository DriverRepository, MerchantRepository The noun it stores
Command Verb + Noun + Command RegisterDriverCommand, GoOnlineCommand, ApproveMerchantKycCommand Present tense — the intent
Query Get/List + Noun + Query GetDriverProfileQuery, ListNearbyDriversQuery, GetMerchantQuery Get (single) or List (multiple)
Handler VerbNoun + Handler RegisterDriverHandler, GoOnlineHandler, ApproveMerchantKycHandler Matches Command/Query name
Service VerbNoun + Service RegisterDriverService, UpdateDriverStatusService, ApproveMerchantKycService Describes the use case
DTO Noun + Dto DriverProfileDto, MerchantDto What data it carries
Eloquent Model Noun + Model DriverModel, MerchantModel Suffix distinguishes from Domain Entity
Eloquent Repository Eloquent + Noun + Repository EloquentDriverRepository, EloquentMerchantRepository Prefix shows implementation type
Service Provider Noun + ServiceProvider DriverServiceProvider, MerchantServiceProvider Laravel convention
Learn & Grow
Learning roadmap

A structured plan to go from "I understand normal MVC" to "I can confidently build and extend DDD projects." Estimated time based on studying 1–2 hours per day.

1
Understand the "why" — Week 1
Before touching code, understand the problem DDD solves. Read about the "Big Ball of Mud" anti-pattern. Look at a large MVC codebase and identify where business rules are scattered. Read the comments in your Driver.php entity carefully — notice what it explicitly says it does NOT own (GPS history, auth, delivery assignments). That intentional scoping IS DDD thinking.
Conceptual No code yet
2
Trace one complete request — Week 2
Pick the "register merchant" flow. Trace it manually: Route → Controller → Command → Handler → Service → Entity → Repository → Database. For each file, write in a notebook: what is this file's job? What does it NOT do? Don't move to the next file until you can explain the current one in plain English.
Reading No writing yet
3
Write unit tests for Domain — Week 3
Write PHPUnit tests for the Domain Entities without setting up a database. Test: Does Driver::goOnline() throw when status is Busy? Does Merchant::approveKyc() throw when status is Pending? Does successRate() return 0.75 when 3 of 4 deliveries succeed? These tests run in under 10ms — no database, no HTTP, no Laravel.
TDD PHPUnit
4
Build a small feature end-to-end — Week 4–5
Add "deactivate merchant" feature yourself: Domain method deactivate() on the Entity, a DeactivateMerchantCommand, a Handler, a Service, and a Controller action. Don't look at existing code until you've tried. Your mistakes will teach you more than reading. Aim for: the feature must be testable without a database.
Hands-on Full feature
5
Learn Domain Events and Listeners — Week 6
Implement a listener for MerchantKycApproved that sends a welcome email. Implement a listener for DriverWentOnline that logs to analytics. Notice: you add these without changing the Entity or Service. This is the "Open/Closed Principle" — open for extension (add listener), closed for modification (don't change entity).
Events Listeners
6
Read selectively from the "blue book" — Month 2
Eric Evans' "Domain-Driven Design: Tackling Complexity in the Heart of Software" (the blue book). Don't read cover-to-cover yet. Read: Chapter 5 (Entities), Chapter 6 (Value Objects and Services), Chapter 7 (Aggregates and Repositories), Chapter 8 (Domain Events). Skip strategic patterns for now. You'll recognize everything from your own code.
Book Selective reading
7
Study how modules communicate — Month 3
Build a new "Delivery" module. It needs to reference both Merchant and Driver. Practice using UUIDs only — never importing their Entities. Build a DeliveryCreatedEvent that the Merchant module listens to (to deduct credit). This is Bounded Context integration — the hardest and most important DDD concept.
Advanced Bounded Contexts
8
Enforce the architecture — Month 3+
Add architecture tests using pest-arch or PHPStan rules that prevent: Domain classes importing from Infrastructure. Controllers importing from Domain directly (no Entity in Controller). Cross-module Entity imports. These tests run in CI and prevent regression as the team grows.
Tooling CI/CD
Learn & Grow
How to think when building new features

When you get a new feature request, here's the mental process experienced DDD developers use. Follow these questions in order — they guide you to put each piece in the right place.

1
What bounded context (module) does this belong to?
Is this about a Driver? → Driver module. About a Merchant? → Merchant module. About a Delivery? → Maybe a new Delivery module. Each feature belongs to one module.
2
What Entity owns this behaviour?
"Is the rule purely about a Driver's own data?" → Add a method to Driver.php. "Does it involve querying multiple entities?" → Application Service. If unsure, prefer Entity — the Domain should be expressive.
3
What Command or Query represents the intent?
Write the Command/Query class first, before any other code. Its name should read like a sentence: SuspendDriverCommand, UpdateMerchantBillingModeCommand, GetMerchantsByKycStatusQuery.
4
Write the Entity method + unit test first (TDD)
Write Driver.suspend(reason), then write the test: "suspended driver cannot go online". Make the test pass before writing Handler/Service. This ensures the Domain is correct before wiring it up.
5
Write the Handler and Service (the "plumbing")
Handler is always thin (3-5 lines). Service follows the pattern: Load → Act → Save → Dispatch. If your Service is more than 40 lines, you're probably doing too much — consider splitting into two Services.
6
What Domain Events should fire?
Ask: "Who else in the system cares when this happens?" If you suspend a driver, should the active delivery be cancelled? Should the driver get an SMS? Create a DriverSuspended event. Each reaction is a separate Listener.
7
Wire up the Controller and Route last
The Presentation layer is always the last thing you write. Validate HTTP input, create the Command, call the Handler, return a DTO. The Controller should take 5 minutes to write — all the complexity is already handled by the layers below.
🗺️ The mental model

Think of your system as a real company. Each module is a department. The Domain is the "policy manual" — the rules of how the business works. The Application layer is the "process manual" — step-by-step instructions for handling requests. The Infrastructure is the IT system. The Presentation is the customer-facing interface. When you ask "where does this code go?" — ask yourself: "Is this a business rule (Domain), a process step (Application), a technical detail (Infrastructure), or an interface concern (Presentation)?"

Quick reference card

QuestionAnswer lives inExample
Is this action allowed?Domain Entity methodDriver.goOnline() checks canGoOnline()
How is X calculated?Domain Entity or Value ObjectDriver.successRate()
What happened?Domain EventDriverWentOnline
What should happen next?Domain Event ListenerSendDriverOnlineNotification
What are the steps for this use case?Application ServiceRegisterDriverService
How do I find/save X?Repository (interface in Domain, SQL in Infrastructure)DriverRepository.findAvailableNear()
How is the HTTP input formatted?Presentation Controller validation$request->validate(['lat' => 'numeric'])
How is the response formatted?DTO + ControllerDriverProfileDto.toArray()
How do modules communicate?UUIDs + Domain EventsDriver references Merchant by UUID only

Final thought — DDD is a conversation, not a formula

DDD is not about perfectly following rules. It's about having a deep conversation with your business domain and encoding it faithfully in code. The best DDD code reads like a business specification. When a non-developer reads merchant.approveKyc() they should understand exactly what it does. When they read driver.canGoOnline() they should be able to explain the rule.

Your project already does this beautifully. The comments in Driver.php say "Does NOT own: individual GPS pings (that's the Tracking context)." That intentional scoping, that clear responsibility boundary — that is what DDD is really about.

The patterns (Commands, Handlers, Repositories, DTOs) are just tools to enforce that clarity. Master the thinking, and the patterns will feel natural.