Start a Laravel app
the DDD way
Most tutorials start with code. This guide starts with thinking — because DDD is a design philosophy before it's a folder structure. Follow these 10 steps in order and you'll build a production-ready DDD Laravel app from a blank screen.
What is your "domain"?
Your domain is the real-world problem your application solves. Before writing any PHP, answer these questions about your app:
- What does this app do? — Describe it in one sentence using business language, not technical terms. "Merchants place delivery orders, drivers fulfil them."
- Who are the actors? — List every person or system that interacts: Admin, Merchant, Driver, Customer, Payment Gateway.
- What are the core business concepts? — The nouns: Order, Delivery, Driver, Merchant, Payment, Route, Zone.
- What are the business rules? — "A driver must be online to receive orders." "A merchant must pass KYC before placing orders." Write every rule down.
- What are the business events? — Things that happen: Order Placed, Driver Assigned, Delivery Completed, Payment Settled.
Identify your Bounded Contexts (modules)
A Bounded Context is an independent part of the business with its own rules and language. Group your business concepts into natural clusters. Each cluster becomes a module (folder) in your code.
Ask yourself: "Could a separate small team own this area completely, without needing to know the details of other areas?" If yes — it's a Bounded Context. The word "Driver" might mean different things in Dispatch context (location, availability) vs Payment context (earnings, bank account). That ambiguity = separate contexts.
Example: Delivery app Bounded Contexts
Everything about businesses that send packages. KYC verification, billing settings, business profiles. Rules: must verify KYC before ordering.
Merchant KycStatus BillingMode
Everything about couriers. GPS tracking, availability, performance. Rules: must be Online to receive dispatches, cannot go Online while Busy.
Driver DriverStatus DriverLocation
The actual delivery order lifecycle. Pickup, dropoff, route, status tracking. References Merchant and Driver by UUID only.
Order Delivery Route
Money movement — merchant charges, driver payouts, refunds. Has its own concept of "transaction" — unrelated to other contexts.
Transaction Payout Wallet
Write your Ubiquitous Language
Ubiquitous Language means: everyone on the team uses the same words for the same things. Write a glossary. This directly becomes your class names.
// Your glossary becomes your code vocabulary // If the business says "go online" → your method is goOnline(), not activate() or setAvailable() // If the business says "KYC" → your class is KycStatus, not VerificationStatus // If the business says "delivery fee" → your field is deliveryFee, not price or cost // ✅ Good — matches business language exactly: $driver->goOnline($location); $merchant->approveKyc(); $order->assignDriver($driverUuid); // ❌ Bad — technical names that don't match business language: $driver->setStatus('active'); $merchant->updateVerification(true); $order->linkEntity($driverUuid);
A list of your Bounded Contexts (modules), a glossary of business terms, a list of business rules, and a list of domain events. Written on paper — no code yet.
Install Laravel
# Create a new Laravel project composer create-project laravel/laravel delivery-app cd delivery-app # Or with Laravel installer laravel new delivery-app --git
Create the src/ directory structure
By default, Laravel uses app/ for everything.
In DDD, your modules live in src/ — separate from Laravel's own framework code in app/.
# Create the top-level structure
mkdir -p src/Shared/Domain/ValueObjects
mkdir -p src/Shared/Domain/Events
mkdir -p src/Shared/Domain/Exceptions
mkdir -p src/Shared/Domain/Contracts
mkdir -p src/Shared/Application
mkdir -p src/Shared/Infrastructure/Bus
mkdir -p src/Shared/Infrastructure/Providers
Configure Composer autoloading
Open composer.json and add your src/ directory to the PSR-4 autoloader.
This tells PHP where to find your namespace.
{
"autoload": {
"psr-4": {
"App\\": "app/",
"DeliveryApp\\": "src/" ← add this line
}
}
}
# After editing composer.json, regenerate the autoloader
composer dump-autoloadcomposer.json
Keep app/ lean — what stays there
| Location | What goes here | What does NOT go here |
|---|---|---|
app/ | Laravel's bootstrap files, global exception handler, HTTP kernel, middleware, console commands (thin shells only) | Business logic, Domain entities, Application services |
src/ | All your DDD modules: Shared, Driver, Merchant, Delivery, Payment | Laravel framework files |
database/ | Migrations, seeders, factories | Business logic |
tests/ | All tests — Unit (Domain), Feature (API) |
Recommended packages to install now
# Authentication (for API tokens) composer require laravel/sanctum # Role/permission middleware (for admin/driver/merchant roles) composer require spatie/laravel-permission # Dev tools composer require --dev pestphp/pest pestphp/pest-plugin-laravel composer require --dev nunomaduro/larastan phpstan/phpstan # Optional: Architecture testing (enforce DDD rules in CI) composer require --dev pest-plugin/arch
Don't install packages that mix business logic into Laravel (like "auto-generated CRUD" packages). They work against DDD — they couple business rules to the framework.
Configure strict types globally
Add declare(strict_types=1); at the top of every PHP file in src/. This prevents accidental type coercion and catches bugs at runtime.
<?php declare(strict_types=1); ← every file in src/ starts with this namespace DeliveryApp\Driver\Domain\Entities; final class Driver extends AggregateRoot { ... }
Laravel installed. src/ folder created. Composer autoloading configured for DeliveryApp\ namespace. Packages installed. You're ready to build.
Why Shared comes first
Every Domain Entity will extend AggregateRoot.
Every entity will use Uuid. Every event will extend DomainEvent.
Every service will type-hint DomainEventBus.
If you build a module first, you'll be inventing these ad-hoc — and doing it differently for each module.
Build Shared first. Build it once. Build it correctly.
Build these files in this order
$occurredAt, abstract eventName(), abstract toArray(). Every event in every module extends this.abstract class DomainEvent { public readonly DateTimeImmutable $occurredAt; public function __construct() { $this->occurredAt = new DateTimeImmutable(); } abstract public function eventName(): string; abstract public function toArray(): array; }src/Shared/Domain/Events/DomainEvent.php
RuntimeException. Used as a marker — caught by global handler → HTTP 422.abstract class DomainException extends RuntimeException {} src/Shared/Domain/Exceptions/DomainException.php
$pendingEvents[], protected recordEvent(), public pullDomainEvents().dispatch(DomainEvent) and dispatchAll(iterable). Lives in Contracts/ because Infrastructure implements it.Uuid and Email. A delivery app needs all six. Don't add Value Objects speculatively.DomainEventBus using Illuminate\Contracts\Events\Dispatcher. Forwards each event to Laravel's own event system.DomainEventBus::class → LaravelDomainEventBus::class as a singleton. Register this provider first in bootstrap/providers.php.// bootstrap/providers.php return [ SharedKernelServiceProvider::class, ← first! DriverServiceProvider::class, MerchantServiceProvider::class, ];bootstrap/providers.php
Register the global DomainException handler
// bootstrap/app.php (Laravel 11) or app/Exceptions/Handler.php (Laravel 10) use DeliveryApp\Shared\Domain\Exceptions\DomainException; $exceptions->render(function (DomainException $e) { return response()->json([ 'error' => 'business_rule_violation', 'message' => $e->getMessage(), ], 422); }); // Write this once — every module's domain exceptions are caught automaticallybootstrap/app.php
Shared Kernel is complete. Every module you build from now on imports from here. You should have tests for every Value Object already — they're the most critical code in your system.
Run these commands to scaffold one module
# Replace "Driver" with your module name (Merchant, Order, Payment, etc.)
MODULE=Driver
mkdir -p src/$MODULE/Presentation/Http/Controllers
mkdir -p src/$MODULE/Presentation/Http/Requests
mkdir -p src/$MODULE/Presentation/Http/Resources
mkdir -p src/$MODULE/Presentation/Routes
mkdir -p src/$MODULE/Application/Commands
mkdir -p src/$MODULE/Application/Queries
mkdir -p src/$MODULE/Application/Handlers
mkdir -p src/$MODULE/Application/Services
mkdir -p src/$MODULE/Application/DTOs
mkdir -p src/$MODULE/Application/Listeners
mkdir -p src/$MODULE/Domain/Entities
mkdir -p src/$MODULE/Domain/ValueObjects
mkdir -p src/$MODULE/Domain/Events
mkdir -p src/$MODULE/Domain/Exceptions
mkdir -p src/$MODULE/Domain/Repositories
mkdir -p src/$MODULE/Infrastructure/Persistence/Eloquent/Models
mkdir -p src/$MODULE/Infrastructure/Persistence/Eloquent/Repositories
mkdir -p src/$MODULE/Infrastructure/Providers
mkdir -p src/$MODULE/Infrastructure/Jobs
The resulting structure
The rule about what goes in each folder
| Folder | The one question to ask | If yes → put it here |
|---|---|---|
Domain/Entities/ | Is this the main business object with identity and rules? | Driver, Merchant, Order |
Domain/ValueObjects/ | Is this a concept described by its value, not identity? Is it immutable? | DriverStatus, DriverLocation, KycStatus |
Domain/Events/ | Is this something that happened in the business (past tense)? | DriverWentOnline, MerchantKycApproved |
Domain/Exceptions/ | Is this a violated business rule? | InvalidDriverStatusTransition |
Domain/Repositories/ | Is this a list of methods for finding/saving the aggregate? (interface only!) | DriverRepository (interface) |
Application/Commands/ | Is this a data object representing a write intent? | RegisterDriverCommand, GoOnlineCommand |
Application/Services/ | Is this orchestrating Load → Act → Save → Dispatch? | RegisterDriverService |
Application/DTOs/ | Is this a safe output shape for returning to the Presentation layer? | DriverProfileDto |
Infrastructure/Persistence/ | Is this touching Eloquent, SQL, or the database? | EloquentDriverRepository, DriverModel |
Presentation/Http/ | Is this handling HTTP input/output? | DriverController, routes |
All folders exist. All files are empty (or don't exist yet). You have a clear map of where everything will go. Now start filling it in — always starting from the Domain layer inward.
5a — Domain Exceptions first
Write your exceptions before your entities, because your entities will throw them.
// src/Driver/Domain/Exceptions/InvalidDriverStatusTransition.php declare(strict_types=1); namespace DeliveryApp\Driver\Domain\Exceptions; use DeliveryApp\Shared\Domain\Exceptions\DomainException; ← extends Shared use DeliveryApp\Driver\Domain\ValueObjects\DriverStatus; final class InvalidDriverStatusTransition extends DomainException { public static function from(DriverStatus $from, DriverStatus $to): self { return new self( "Invalid transition: {$from->value} → {$to->value}" ); } }Domain/Exceptions/
5b — Value Objects next
// src/Driver/Domain/ValueObjects/DriverStatus.php enum DriverStatus: string { case Offline = 'offline'; case Online = 'online'; case Busy = 'busy'; case OnBreak = 'on_break'; case Suspended = 'suspended'; public function isAvailable(): bool { return $this === self::Online; } public function canGoOnline(): bool { return match($this) { self::Offline, self::OnBreak => true, default => false, }; } } // ↑ Business rules live on the Value Object itself. // No if-strings scattered across the codebase.Domain/ValueObjects/DriverStatus.php
5c — Domain Events
// src/Driver/Domain/Events/DriverWentOnline.php use DeliveryApp\Shared\Domain\Events\DomainEvent; ← extends Shared use DeliveryApp\Shared\Domain\ValueObjects\Uuid; use DeliveryApp\Driver\Domain\ValueObjects\DriverLocation; final class DriverWentOnline extends DomainEvent { public function __construct( public readonly Uuid $driverId, public readonly DriverLocation $location, ) { parent::__construct(); } public function eventName(): string { return 'driver.went_online'; } public function toArray(): array { return ['driver_id' => (string)$this->driverId, ...]; } }Domain/Events/DriverWentOnline.php
5d — Repository Interface (no SQL!)
// src/Driver/Domain/Repositories/DriverRepository.php // Pure PHP interface. ZERO Eloquent. ZERO SQL. The Domain says what it NEEDS. interface DriverRepository { public function findByUuid(Uuid $uuid): ?Driver; public function findByUserUuid(Uuid $userUuid): ?Driver; public function save(Driver $driver): void; public function findAvailableNear(Coordinates $origin, float $radiusKm, int $limit): iterable; }Domain/Repositories/DriverRepository.php
5e — The Aggregate Root Entity (last, because it uses everything above)
// src/Driver/Domain/Entities/Driver.php use DeliveryApp\Shared\Domain\ValueObjects\AggregateRoot; ← Shared use DeliveryApp\Shared\Domain\ValueObjects\Uuid; ← Shared use DeliveryApp\Driver\Domain\ValueObjects\DriverStatus; ← own module use DeliveryApp\Driver\Domain\Events\DriverWentOnline; ← own module use DeliveryApp\Driver\Domain\Exceptions\InvalidDriverStatusTransition; final class Driver extends AggregateRoot { private function __construct( public readonly Uuid $id, public readonly Uuid $userId, private DriverStatus $status, private ?DriverLocation $lastLocation, private float $rating, // ... other fields ) {} // ═══ Factory: create brand new driver ═══ public static function register(Uuid $userId, string $fullName, ...): self { return new self( id: Uuid::generate(), status: DriverStatus::Offline, ← always starts Offline // ... ); } // ═══ Factory: rebuild from database ═══ public static function hydrate(Uuid $id, DriverStatus $status, ...): self { return new self($id, $status, ...); } // ═══ Business behaviour ═══ public function goOnline(DriverLocation $location): void { if (! $this->status->canGoOnline()) { throw InvalidDriverStatusTransition::from($this->status, DriverStatus::Online); } $this->status = DriverStatus::Online; $this->lastLocation = $location; $this->recordEvent(new DriverWentOnline($this->id, $location)); } }Domain/Entities/Driver.php
The Domain layer is pure PHP — tests run in milliseconds. Write tests for every entity method and every Value Object now.
it('throws when going online while busy'),
it('starts with Offline status'),
it('records DriverWentOnline event').
If you skip tests here and move on, you'll never go back.
Write in this order for each use case
final class RegisterDriverCommand { public function __construct( public readonly string $userUuid, public readonly int $vehicleTypeId, public readonly string $fullName, public readonly string $licenseNo, ) {} // No methods. No logic. Just data. }Application/Commands/RegisterDriverCommand.php
fromAggregate() factory method. The Controller receives this, never the raw Entity.final class DriverProfileDto { public function __construct( public readonly string $uuid, public readonly string $fullName, public readonly string $status, ← string, not DriverStatus enum public readonly float $rating, public readonly ?array $lastLocation, ) {} public static function fromAggregate(Driver $d): self { return new self( uuid: (string) $d->id, fullName: $d->fullName(), status: $d->status()->value, rating: $d->rating(), lastLocation: $d->lastLocation()?->toArray(), ); } public function toArray(): array { return [...]; } }Application/DTOs/DriverProfileDto.php
final class RegisterDriverService { public function __construct( private readonly DriverRepository $repository, ← interface private readonly DomainEventBus $eventBus, ← interface ) {} public function execute(RegisterDriverCommand $cmd): Driver { return DB::transaction(function() use ($cmd) { // 1. Create (Domain factory handles defaults) $driver = Driver::register( userId: new Uuid($cmd->userUuid), vehicleTypeId:$cmd->vehicleTypeId, fullName: $cmd->fullName, licenseNo: $cmd->licenseNo, ); // 2. Save $this->repository->save($driver); // 3. Dispatch events $this->eventBus->dispatchAll($driver->pullDomainEvents()); return $driver; }); } }Application/Services/RegisterDriverService.php
final class RegisterDriverHandler { public function __construct( private readonly RegisterDriverService $service ) {} public function handle(RegisterDriverCommand $cmd): DriverProfileDto { $driver = $this->service->execute($cmd); return DriverProfileDto::fromAggregate($driver); } }Application/Handlers/RegisterDriverHandler.php
Query services often skip the Entity altogether and query the Eloquent model directly for performance.
GetDriverProfileService might just call DriverModel::where('uuid', ...)->first()
and map it to a DTO — without loading the full Domain Entity. This is acceptable and even recommended
for read paths that don't need business rules.
7a — Write the migration first
# Create the migration
php artisan make:migration create_drivers_table
// database/migrations/xxxx_create_drivers_table.php Schema::create('drivers', function (Blueprint $table) { $table->id(); $table->uuid('uuid')->unique(); $table->foreignId('user_id')->constrained(); $table->string('full_name'); $table->string('license_no')->unique(); $table->string('status')->default('offline'); $table->decimal('current_lat', 10, 7)->nullable(); $table->decimal('current_lng', 10, 7)->nullable(); $table->unsignedInteger('total_deliveries')->default(0); $table->decimal('rating', 4, 2)->default(0.0); $table->timestamps(); $table->softDeletes(); });database/migrations/
7b — Eloquent Model (dumb data mapper)
// src/Driver/Infrastructure/Persistence/Eloquent/Models/DriverModel.php final class DriverModel extends Model ← extends Eloquent Model { use SoftDeletes; protected $table = 'drivers'; protected $guarded = []; protected $casts = [ 'current_lat' => 'float', 'current_lng' => 'float', 'rating' => 'float', ]; // Auto-generate UUID on creation protected static function booted(): void { static::creating(fn(self $m) => $m->uuid ??= (string) Str::uuid()); } // Relationships OK here — no business methods! public function user(): BelongsTo { return $this->belongsTo(User::class); } } // ↑ NO isAvailable(), NO canGoOnline(), NO goOnline() here // Those live in Domain/Entities/Driver.php onlyInfrastructure/Eloquent/Models/DriverModel.php
7c — Eloquent Repository (implements the Domain interface)
// src/Driver/Infrastructure/Persistence/Eloquent/Repositories/EloquentDriverRepository.php final class EloquentDriverRepository implements DriverRepository ← interface from Domain { // READ: DB row → Domain Entity public function findByUuid(Uuid $uuid): ?Driver { $model = DriverModel::query()->where('uuid', $uuid->value)->first(); return $model ? $this->toDomain($model) : null; } // WRITE: Domain Entity → DB row public function save(Driver $driver): void { $model = DriverModel::firstOrNew(['uuid' => (string)$driver->id]); $model->fill([ 'user_id' => $this->resolveUserId($driver->userId), 'full_name' => $driver->fullName(), 'status' => $driver->status()->value, ← enum → string 'current_lat' => $driver->lastLocation() ?->coordinates->latitude, 'current_lng' => $driver->lastLocation() ?->coordinates->longitude, 'rating' => $driver->rating(), ]); $model->save(); } // HYDRATION: Eloquent model → Domain Entity private function toDomain(DriverModel $m): Driver { return Driver::hydrate( id: new Uuid((string)$m->uuid), status: DriverStatus::from((string)$m->status), ← string → enum rating: (float)$m->rating, // ... other fields ); } }Infrastructure/Repositories/EloquentDriverRepository.php
7d — Service Provider (the wiring)
// src/Driver/Infrastructure/Providers/DriverServiceProvider.php final class DriverServiceProvider extends ServiceProvider { public function register(): void { // Bind interface → implementation $this->app->bind( DriverRepository::class, EloquentDriverRepository::class, ); } public function boot(): void { // Register module routes Route::middleware('api') ->prefix('api/v1') ->group(base_path('src/Driver/Presentation/Routes/api.php')); } }Infrastructure/Providers/DriverServiceProvider.php
The 3-line controller rule
Every controller action should do exactly three things, nothing more:
Check raw HTTP input format. Required fields, data types, basic uniqueness. Nothing business-related.
Package input into a Command/Query. Call the Handler. The Controller's job is done — it waits.
Format the DTO from the Handler into a JSON response with the correct HTTP status code.
A complete thin controller
// src/Driver/Presentation/Http/Controllers/DriverController.php final class DriverController extends Controller { public function __construct( private readonly RegisterDriverHandler $registerHandler, private readonly GoOnlineHandler $onlineHandler, private readonly GetDriverProfileHandler $profileHandler, ) {} // ── POST /api/v1/drivers ── public function store(Request $request): JsonResponse { // Step 1: validate raw HTTP format only $v = $request->validate([ 'user_uuid' => ['required', 'uuid'], 'full_name' => ['required', 'string', 'max:255'], 'license_no' => ['required', 'string', 'unique:drivers,license_no'], 'vehicle_type_id' => ['required', 'integer', 'exists:vehicle_types,id'], ]); // Step 2: delegate to Handler via Command $dto = $this->registerHandler->handle(new RegisterDriverCommand( userUuid: $v['user_uuid'], vehicleTypeId: (int) $v['vehicle_type_id'], fullName: $v['full_name'], licenseNo: $v['license_no'], )); // Step 3: respond return response()->json(['data' => $dto->toArray()], 201); } // ── POST /api/v1/drivers/me/online ── public function goOnline(Request $request): JsonResponse { $v = $request->validate([ 'lat' => ['required', 'numeric', 'between:-90,90'], 'lng' => ['required', 'numeric', 'between:-180,180'], 'heading' => ['nullable', 'numeric'], ]); $driverUuid = $this->resolveDriverUuid($request); $this->onlineHandler->handle(new GoOnlineCommand( driverUuid: $driverUuid, lat: (float) $v['lat'], lng: (float) $v['lng'], )); return response()->json(['data' => ['status' => 'online']], 202); } }Presentation/Http/Controllers/DriverController.php
Routes file — co-located with the module
// src/Driver/Presentation/Routes/api.php Route::middleware('auth:sanctum')->group(function () { // Admin-only actions Route::middleware('role:admin')->group(function () { Route::post('/drivers', [DriverController::class, 'store']); Route::get('/admin/drivers/nearby', [DriverController::class, 'nearby']); }); // Driver self-service actions Route::prefix('drivers/me')->group(function () { Route::get('/', [DriverController::class, 'me']); Route::post('/online', [DriverController::class, 'goOnline']); Route::post('/offline',[DriverController::class, 'goOffline']); Route::post('/ping', [DriverController::class, 'ping']); }); });Presentation/Routes/api.php
No Eloquent queries. No Driver::where(...).
No business rules. No if ($driver->status === 'busy').
No email sending. No Mail::send(...).
No event dispatching directly. No event(new DriverWentOnline(...)).
If you see any of these in a Controller, move them to the right layer.
The end-to-end verification checklist
- SharedKernelServiceProvider registered first in
bootstrap/providers.php - DriverServiceProvider registered after Shared
- Migration runs:
php artisan migrate - Routes appear:
php artisan route:list | grep driver - Container resolves:
php artisan tinker → app(DriverRepository::class) - POST /api/v1/drivers returns 201 with driver profile JSON
- POST /api/v1/drivers/me/online returns 202
- Sending invalid status transition returns 422 with the business error message
Three types of tests you need
No database. No Laravel. Milliseconds per test. Test every Entity method, every Value Object, every Domain Event.
// tests/Unit/Driver/Domain/ it('starts with Offline status', function() { $d = Driver::register(...); expect($d->status())->toBe(DriverStatus::Offline); }); it('throws when going online while busy', function() { $d = Driver::register(...); $d->markBusy(); expect(fn() => $d->goOnline($loc)) ->toThrow(InvalidDriverStatusTransition::class); });
Uses a test database. Tests that save() and findByUuid() work correctly — that hydration round-trips without data loss.
// tests/Integration/Driver/ it('persists and retrieves driver', function() { $repo = app(DriverRepository::class); $driver = Driver::register(...); $repo->save($driver); $found = $repo->findByUuid($driver->id); expect($found->id->equals($driver->id))->toBeTrue(); });
Full HTTP request through the whole stack. Tests the contract your API clients depend on.
// tests/Feature/Driver/ it('registers a driver via API', function() { $user = User::factory()->create(); $this->actingAs($user) ->postJson('/api/v1/drivers', [...]) ->assertCreated() ->assertJsonPath('data.status', 'offline'); });
Running tests
# Run all tests php artisan test # Run only Domain unit tests (fastest — no DB) ./vendor/bin/pest tests/Unit --filter=Driver # Run with coverage ./vendor/bin/pest --coverage # Run architecture tests (enforce DDD rules) ./vendor/bin/pest --filter=arch
Optional: Enforce DDD rules in tests
// tests/Arch/DddRulesTest.php — automatic architecture enforcement // Domain must not import from Laravel arch()->expect('DeliveryApp\Driver\Domain') ->toUseNothing()->except('DeliveryApp\Shared'); // Controllers must not use Eloquent directly arch()->expect('DeliveryApp\Driver\Presentation') ->not()->toUse('Illuminate\Database\Eloquent\Model'); // Services must not extend Model arch()->expect('DeliveryApp\Driver\Application') ->not()->toExtend('Illuminate\Database\Eloquent\Model'); // These run in CI and fail the build if any rule is brokenArchitecture tests
The golden rule of multi-module apps
Modules communicate via UUIDs and Domain Events only.
Never import one module's Entity into another module's code.
// ❌ WRONG: Delivery imports Driver Entity namespace DeliveryApp\Delivery; use DeliveryApp\Driver\Domain\Entities\Driver; // ↑ Delivery is now coupled to Driver's internals. // Changing Driver breaks Delivery. class Order { private ?Driver $assignedDriver; ← ❌ }
// ✅ CORRECT: Reference by UUID only namespace DeliveryApp\Delivery; use DeliveryApp\Shared\Domain\ValueObjects\Uuid; // ↑ Only depends on Shared — always safe class Order { private ?Uuid $assignedDriverId; ← ✅ // Just the ID. Delivery doesn't need Driver's rules. }
Cross-module communication via Events
// When a delivery is created, the Merchant module needs to deduct credit. // But Delivery must NOT import Merchant's Entity. // Solution: Domain Events + Listeners // Delivery module fires an event: $this->recordEvent(new OrderCreated( orderId: $this->id, merchantId: $this->merchantId, ← just a Uuid amount: $this->deliveryFee, ← a Money VO from Shared )); // Merchant module listens, using its own repository to load its Entity: class DeductCreditOnOrderCreatedListener { public function handle(OrderCreated $event): void { $merchant = $this->merchantRepository->findByUuid($event->merchantId); $merchant->deductCredit($event->amount); ← Merchant's own method $this->merchantRepository->save($merchant); } } // The Delivery module has no idea the Merchant module exists. // The Merchant module has no idea the Delivery module exists. // They only share the OrderCreated event class.Cross-module via Events
The order in which to build modules
auth:sanctum middleware and $request->user().Your complete project after all modules
The mental checklist for every new feature
- Which module does this belong to?
- Which Entity owns this behaviour? Or is it a new Entity?
- Write the Domain method + unit test first.
- Write the Command (if write) or Query (if read).
- Write the Service (Load → Act → Save → Dispatch).
- Write the Handler (3–5 lines, thin delegator).
- Does something else in the system need to react? Write a Domain Event + Listener.
- Does the Repository need a new method? Add to interface, implement in Eloquent Repository.
- Write the Controller action (validate → delegate → respond).
- Add the route. Write a Feature test. Run all tests.
Step 1 is the only step you can do right now — no computer needed. Take your application idea, grab a notebook, and write down your bounded contexts, business rules, and domain events. The code follows naturally from that clarity.
Remember: DDD is not a set of folders. It's the discipline of making your code mirror the real business — so that when the business changes, the code changes in exactly the right place, in exactly the right way.