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.
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.
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."
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 |
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.
Your project is named DeliveryApp. It has two bounded contexts
(independent business departments): Merchant and Driver.
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.
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
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.
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.
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 breaks | Real 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. |
Both the Driver and Merchant modules follow identical folder structures. Here is every folder and file, with its purpose.
Driver/ module
Merchant/ module
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.
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."
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?
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.
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.
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.
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
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:
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
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.
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.
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 Object | With 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/
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.
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
Driver.goOnline() calls $this->recordEvent(new DriverWentOnline($this->id, $location)). The event is stored in memory on the Entity object — NOT dispatched yet.RegisterDriverService calls $this->eventBus->dispatchAll($driver->pullDomainEvents()). This is safe — the DB is already committed before events fire.// 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
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.
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.
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).
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.
CQRS stands for Command Query Responsibility Segregation. Simple idea: actions that change data (Commands) and actions that read data (Queries) are kept completely separate.
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.
Change state. Have side effects. Return nothing or a result DTO.
RegisterDriverCommand GoOnlineCommand GoOfflineCommand UpdateDriverLocationCommand RegisterMerchantCommand ApproveMerchantKycCommand RejectMerchantKycCommand
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/
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).
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 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
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.
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.
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.
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.
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
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()
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
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.
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.
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
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.
{
"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"
}
DriverServiceProvider registered two things: (a) the route POST /api/v1/drivers → DriverController::store() and (b) the binding DriverRepository → EloquentDriverRepository.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.$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.phpRegisterDriverCommand (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.phpRegisterDriverHandler::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.DB::transaction(function() {...}) starts. If anything inside fails (exception), the entire transaction rolls back. This ensures the database never gets into an inconsistent state.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$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.$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.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.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.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"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
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)
Ahmed Al-Rashidi is a motorcycle courier. Here's his complete journey through the code.
Ahmed's driver status flow
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
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.
auth:sanctum middleware — applied at the route level. Completely outside the DDD layers.role:admin|super-admin. Or Domain Entity method: merchant->canCreateDeliveries(). Never in Services or Repositories.$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
There are two kinds of validation in DDD, and they live in different places.
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'], ]);
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::...;
}
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.
The golden rule: if it's a business question, it belongs in the Domain.
Domain/Entities/Driver.php → goOnline() — enforces the status transition rule.Domain/Entities/Merchant.php → canCreateDeliveries() — checks KYC status and active flag.Domain/Entities/Driver.php → successRate() — computed from stored counters.Domain/ValueObjects/DriverLocation.php → isStale() — belongs on the Value Object.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
Infrastructure/Persistence/Eloquent/Repositories/ — EloquentDriverRepository, EloquentMerchantRepository. All SQL lives here.Infrastructure/ — create an adapter class. Example: Infrastructure/Messaging/TwilioSmsAdapter.php. Implement a Domain interface. Never call external APIs from Services directly.Application/Listeners/ — subscribe to Domain Events. Example: SendKycApprovedEmailListener listens to MerchantKycApproved. The Domain fires the event; the Listener sends the email.Infrastructure/Jobs/ — background jobs that call Application Services. Example: ProcessDriverLocationJob that calls the location service from a queue.Infrastructure/ — cache adapters that implement Repository interfaces with a caching layer. The Domain never knows caching exists.Infrastructure/Storage/ — S3 adapter. Implement a Domain interface like DocumentStorageService.These are the patterns that break DDD. Each one has been made by developers everywhere. Learning to recognize them early saves weeks of refactoring.
// ❌ 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.
// ❌ 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.
// ❌ 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.
// ❌ 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.
// ❌ 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.
// ❌ 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.
Rules and principles that experienced DDD practitioners follow. These come from real projects and real pain.
All Entity properties are private. Expose them only via getter methods. This prevents external code from bypassing business rules.
Each DB transaction should save only ONE aggregate. Use Domain Events to coordinate changes across multiple aggregates asynchronously.
Value Objects should use readonly properties. To "change" a VO, create a new one. This prevents accidental mutation bugs.
Each Application Service handles exactly one use case: RegisterDriverService, ApproveMerchantKycService. Never make "mega services" with 20 methods.
Services depend on DriverRepository (interface), not EloquentDriverRepository (implementation). This makes testing and swapping easy.
Unit test your Domain Entities without a database. $driver = Driver::register(...); $driver->goOnline(...) runs in under 1ms. No Laravel setup needed.
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.
The Domain folder must be free of use Illuminate\... imports. Enforce this with an architecture test using PHPStan or pest-arch.
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().
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 |
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.
Driver.php entity carefully — notice what it explicitly says
it does NOT own (GPS history, auth, delivery assignments). That intentional scoping IS DDD thinking.
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.
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.
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).
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.
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.
Driver.php. "Does it involve querying multiple entities?" → Application Service. If unsure, prefer Entity — the Domain should be expressive.SuspendDriverCommand, UpdateMerchantBillingModeCommand, GetMerchantsByKycStatusQuery.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.DriverSuspended event. Each reaction is a separate Listener.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
| Question | Answer lives in | Example |
|---|---|---|
| Is this action allowed? | Domain Entity method | Driver.goOnline() checks canGoOnline() |
| How is X calculated? | Domain Entity or Value Object | Driver.successRate() |
| What happened? | Domain Event | DriverWentOnline |
| What should happen next? | Domain Event Listener | SendDriverOnlineNotification |
| What are the steps for this use case? | Application Service | RegisterDriverService |
| 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 + Controller | DriverProfileDto.toArray() |
| How do modules communicate? | UUIDs + Domain Events | Driver references Merchant by UUID only |
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.