The Shared folder,
fully explained
The Shared folder is the foundation every module in your app stands on. Understanding it means understanding how DDD modules talk to each other without becoming entangled — explained from zero, using your actual code.
Every file in Shared, with its purpose at a glance.
How Shared connects to the modules
// Every module imports FROM Shared — Shared never imports FROM modules // Driver/Domain/Entities/Driver.php use DeliveryApp\Shared\Domain\ValueObjects\AggregateRoot; // ← from Shared use DeliveryApp\Shared\Domain\ValueObjects\Uuid; // ← from Shared use DeliveryApp\Shared\Domain\ValueObjects\Coordinates; // ← from Shared final class Driver extends AggregateRoot { ... } // Merchant/Domain/Entities/Merchant.php use DeliveryApp\Shared\Domain\ValueObjects\AggregateRoot; // ← same Shared class use DeliveryApp\Shared\Domain\ValueObjects\Uuid; // ← same Shared class use DeliveryApp\Shared\Domain\ValueObjects\Money; // ← from Shared use DeliveryApp\Shared\Domain\ValueObjects\Email; // ← from Shared final class Merchant extends AggregateRoot { ... } // Dependency direction (Shared is the innermost layer): // // Driver module ──→ Shared Kernel // Merchant module──→ Shared Kernel // Delivery module──→ Shared Kernel // ↑ // NO arrows go outward from Shared
This is one of the most important files in the entire project. It defines how modules announce that something happened — without knowing who is listening or how the announcement works.
What is a Contract?
A contract (PHP interface) is a promise, not an implementation.
It says: "Whatever implements me, I guarantee these methods will exist."
The Domain writes contracts for capabilities it needs. Infrastructure provides the actual working code.
When you pick up a phone and dial, you don't know if the call travels over copper wire, fibre optic cable,
satellite, or 5G towers. You just know: "I speak into this device and the other person hears me."
The phone socket is the contract. The technology inside the wall is the implementation.
DomainEventBus is the socket. LaravelDomainEventBus is the wiring in the wall.
The code — line by line
// Shared/Domain/Contracts/DomainEventBus.php interface DomainEventBus { // Fire ONE event: "Something just happened" public function dispatch(DomainEvent $event): void; // Fire ALL events collected by an aggregate after a transaction // @param iterable<DomainEvent> $events public function dispatchAll(iterable $events): void; }Shared/Domain/Contracts/DomainEventBus.php
That's the entire file — 8 lines. But these 8 lines do something profound:
| Without this interface | With this interface |
|---|---|
Every Service imports Illuminate\Contracts\Events\Dispatcher directly |
Every Service imports DomainEventBus — a Domain concept, not a Laravel concept |
| Domain layer depends on Laravel — cannot run without it | Domain layer depends on its own interface — runs without Laravel in tests |
| To swap event systems (e.g. move to RabbitMQ), modify every Service | To swap: write a new RabbitMQDomainEventBus, change one binding in the provider |
How it's used — the full chain
// 1. Application Service declares it needs an event bus (via interface) final class ApproveMerchantKycService { public function __construct( private readonly MerchantRepository $repository, private readonly DomainEventBus $eventBus, // ← interface, not Laravel class! ) {} public function execute(Uuid $merchantUuid): void { $merchant = $this->repository->findByUuid($merchantUuid); $merchant->approveKyc(); // Entity records event $this->repository->save($merchant); // DB saved ✓ $this->eventBus->dispatchAll($merchant->pullDomainEvents()); // Events fired ✓ } } // 2. Laravel injects LaravelDomainEventBus (registered by SharedKernelServiceProvider) // 3. LaravelDomainEventBus forwards to Laravel's own dispatcher // 4. Laravel's dispatcher calls all registered Listeners // Service never knows any of this — it just calls $this->eventBus->dispatchAll()The full chain
Why does this interface live in Shared/Domain instead of each module?
Because every module in the system fires events. Driver services fire events. Merchant services fire events.
Future Delivery and Tracking services will fire events. If you put DomainEventBus in the Driver module,
the Merchant module would have to import from Driver — creating a cross-module dependency. Put it in Shared,
and everyone imports from a neutral place.
Why is it in Contracts/ not Services/?
Contracts/ holds interfaces that represent capabilities the Domain needs from the outside world.
The Domain cannot implement them itself (because the implementation requires Laravel or infrastructure tools).
This is the Dependency Inversion Principle in action:
the Domain defines what it needs, and Infrastructure provides it.
Every event in your entire system — DriverWentOnline, MerchantKycApproved,
future OrderCreated, DeliveryCompleted — extends this single class.
It's the common language all modules use to announce what happened.
The code — every line explained
// Shared/Domain/Events/DomainEvent.php abstract class DomainEvent { // Every event knows WHEN it happened — auto-set on creation public readonly DateTimeImmutable $occurredAt; public function __construct(?DateTimeImmutable $occurredAt = null) { // If you don't pass a time, it uses NOW automatically // You can pass a time for replaying historical events $this->occurredAt = $occurredAt ?? new DateTimeImmutable(); } // Every event must have a human-readable name // Used for logging, debugging, event store entries abstract public function eventName(): string; // Every event must be serialisable to an array // Used for: logging, queuing, API responses, audit trails abstract public function toArray(): array; }Shared/Domain/Events/DomainEvent.php
How a real event inherits from it
// Driver/Domain/Events/DriverWentOnline.php final class DriverWentOnline extends DomainEvent // ← extends Shared base class { public function __construct( public readonly Uuid $driverId, public readonly DriverLocation $location, ) { parent::__construct(); // sets $occurredAt = now() } public function eventName(): string { return 'driver.went_online'; // fulfils abstract requirement } public function toArray(): array { return [ // fulfils abstract requirement 'driver_id' => (string) $this->driverId, 'lat' => $this->location->coordinates->latitude, 'lng' => $this->location->coordinates->longitude, 'recorded_at' => $this->location->recordedAt->format(DATE_ATOM), ]; } }Driver/Domain/Events/DriverWentOnline.php
Domain Events vs Laravel Events — what's the difference?
| Aspect | Domain Event (your DomainEvent.php) | Laravel Event (plain class + event()) |
|---|---|---|
| What it represents | A business fact: "Driver went online" | A technical trigger: "Send email" |
| Where it's created | Inside a Domain Entity (recordEvent()) | Anywhere — Service, Job, Controller |
| When it fires | After DB transaction commits (explicit dispatch) | Immediately when event() is called |
| Depends on Laravel? | No — pure PHP, testable alone | Yes — requires Laravel framework |
| In your project | Domain Events get dispatched through LaravelDomainEventBus which hands them to Laravel's dispatcher | Laravel's dispatcher then calls your Listeners |
Think of it as a relay race. Your Domain Event is the baton — it carries the business fact.
The DomainEventBus is the first runner who hands the baton off.
The LaravelDomainEventBus is the hand-off to Laravel's dispatcher.
The Listeners are the final runners who do the actual work (send email, update analytics).
The baton (DomainEvent) never changes throughout the race.
Why is DomainEvent abstract?
Because you can never fire a generic "something happened" event — every event must be specific.
The abstract keyword forces every child class to implement eventName()
and toArray(). If a developer creates MerchantKycApproved and forgets
toArray(), PHP throws a fatal error at load time — before any code runs. It's a safety net.
When a business rule is violated, the Domain throws a DomainException.
This one abstract class makes it possible for Laravel's error handler to know:
"this is a business rule violation — return HTTP 422."
The code
// Shared/Domain/Exceptions/DomainException.php abstract class DomainException extends RuntimeException { // That's it. No methods. No properties. // It's a MARKER — a tag that says "I am a business rule violation" }Shared/Domain/Exceptions/DomainException.php
The hierarchy — all business errors in your project
DomainException (Shared — the root marker) ├── InvalidDriverStatusTransition (Driver module: "busy → online not allowed") └── InvalidKycTransitionException (Merchant module: "pending → approved not allowed") // Future exceptions you'd add: // ├── InsufficientCreditException (Merchant module: "not enough balance") // ├── DriverNotAvailableException (Driver module: "driver is suspended") // └── DeliveryAreaNotCoveredException (Delivery module: "we don't deliver there")Exception hierarchy
What is "system error" vs "business error"?
| Type | Example | HTTP Status | Class |
|---|---|---|---|
| System error | Database connection failed, disk full, null pointer | 500 Internal Server Error | PHP built-in exceptions |
| Business error | "You can't go online while busy", "KYC not submitted yet" | 422 Unprocessable Entity | DomainException subclass |
| Input error | "lat must be a number", "email is required" | 422 Validation Error | Laravel ValidationException |
How Laravel catches it — the global handler
// app/Exceptions/Handler.php (or bootstrap/app.php in Laravel 11+) use DeliveryApp\Shared\Domain\Exceptions\DomainException; $exceptions->render(function (DomainException $e) { // Catches ANY subclass of DomainException automatically: // InvalidDriverStatusTransition, InvalidKycTransitionException, etc. return response()->json([ 'error' => 'business_rule_violation', 'message' => $e->getMessage(), // Example: "Invalid driver status transition: busy -> online" ], 422); }); // You write this handler ONCE in the app. // Every module's DomainException automatically gets caught and formatted. // No module needs to know about HTTP status codes.app/Exceptions/Handler.php
The Domain throws a DomainException when a rule is broken.
The Domain has no idea that an HTTP API exists — it's just a PHP exception.
The Presentation layer's global handler catches it and converts it to the right HTTP response.
Complete separation: Domain speaks business language, Presentation speaks HTTP language.
Every aggregate in your system — Driver, Merchant, future Order —
extends this class. It provides the event recording mechanism that makes Domain Events possible.
AggregateRoot.php lives in ValueObjects/ — this is a pragmatic folder choice,
not a strict DDD classification. Aggregate Roots are not Value Objects. In larger projects, you'd
have a separate Domain/ folder for it. Here it's grouped with shared domain primitives.
The code — every line explained
// Shared/Domain/ValueObjects/AggregateRoot.php abstract class AggregateRoot { // A private "diary" — events the aggregate has recorded but not yet fired // Private = no module can tamper with it from outside private array $pendingEvents = []; // Called from within Entity methods: "write this in the diary" // protected = only the subclass (Driver, Merchant) can call this protected function recordEvent(DomainEvent $event): void { $this->pendingEvents[] = $event; } // Called by Application Service AFTER saving to DB: // "give me all diary entries and clear the diary" // public = Application layer can call this public function pullDomainEvents(): array { $events = $this->pendingEvents; // copy the events $this->pendingEvents = []; // clear the diary return $events; // return the copy } }Shared/Domain/ValueObjects/AggregateRoot.php
Why events are collected first and dispatched after the DB save
// Application Service — the correct order is CRITICAL public function goOnline(Uuid $driverUuid, ...): void { DB::transaction(function() { $driver = $this->repository->findByUuid($driverUuid); // Step 1: Entity method records the event into $pendingEvents[] // but does NOT fire it yet — the DB is not saved yet! $driver->goOnline($location); // internally calls: $this->recordEvent(new DriverWentOnline(...)) // Step 2: Save to DB — if this fails, we throw and rollback // The event was never fired, so no inconsistency ✅ $this->repository->save($driver); // Step 3: NOW fire the events — DB is already committed // pullDomainEvents() returns the events AND clears the list $this->eventBus->dispatchAll($driver->pullDomainEvents()); // Listeners now run: update live map, log to analytics, etc. }); } // ❌ What if you fired events BEFORE saving? // Listeners might react to an event for data that doesn't exist in the DB yet. // A listener queries the DB for the driver → not found → crash / stale data. // Collect first, save, then fire = safe always.Why the order matters
How Driver and Merchant use it
// Driver extends AggregateRoot — gets recordEvent() and pullDomainEvents() for free final class Driver extends AggregateRoot { public function goOnline(DriverLocation $location): void { // ... status validation ... $this->status = DriverStatus::Online; $this->recordEvent(new DriverWentOnline($this->id, $location)); // ↑ AggregateRoot.recordEvent() — inherited from Shared } } // Merchant extends AggregateRoot — same mechanism final class Merchant extends AggregateRoot { public function approveKyc(): void { // ... KYC validation ... $this->kycStatus = KycStatus::Approved; $this->recordEvent(new MerchantKycApproved($this->id)); // ↑ same AggregateRoot.recordEvent() — same Shared code } }Aggregates using Shared/AggregateRoot
Every entity in your system — Driver, Merchant, Order, Delivery — needs a unique ID.
Uuid is the agreed standard for what that ID looks like and how it's generated.
The code — every part explained
// Shared/Domain/ValueObjects/Uuid.php final class Uuid { public readonly string $value; public function __construct(string $value) { // Validate on construction — an invalid UUID can NEVER exist if (! Str::isUuid($value)) { throw new InvalidArgumentException("Invalid UUID: {$value}"); } $this->value = $value; } // Generate a brand-new UUID v4 (random) public static function generate(): self { return new self((string) Str::uuid()); } // Compare two UUIDs — always string-to-string, case-insensitive public function equals(self $other): bool { return $this->value === $other->value; } // Cast to string automatically when used in string context public function __toString(): string { return $this->value; } }Shared/Domain/ValueObjects/Uuid.php
Why UUID instead of auto-increment integers?
| Problem | Integer ID (1, 2, 3...) | UUID (550e8400-...) |
|---|---|---|
| Security | Predictable: a user can guess that order ID 1001 exists and try /orders/1001 | Unguessable: UUID is random — cannot enumerate |
| Distributed systems | Two servers both insert and get ID=42 — conflict | Generated before DB insert — guaranteed unique globally |
| API design | Exposes business size: "we only have 50 orders" (ID=50) | Reveals nothing about business scale |
| Module isolation | Driver ID=5, Merchant ID=5 — which 5 are you talking about? | UUIDs are globally unique — no ambiguity across modules |
Why the UUID class instead of plain string?
// Without Uuid class — plain string chaos public function findDriver(string $id): ?Driver // Could be anything! { // Called as: findDriver("not-a-uuid") → no error until DB query fails // findDriver("") → no error // findDriver("12345") → no error } // With Uuid class — validated at the border public function findDriver(Uuid $id): ?Driver // Must be valid UUID! { // Cannot reach this method with an invalid UUID // new Uuid("not-a-uuid") throws InvalidArgumentException immediately } // In your controller — validated once at the HTTP boundary: $uuid = new Uuid($request->validated('driver_uuid')); // From this point on, anywhere $uuid flows, it's guaranteed valid.
The comment in your code says: "We deliberately depend on the framework helper because
spinning a third-party UUID lib is overkill." This is a pragmatic decision — a tiny
Laravel dependency in a Value Object is acceptable for this project. In a stricter environment,
you'd use a pure PHP UUID library like ramsey/uuid.
GPS coordinates are used in two places: Driver locations (real-time tracking)
and Address locations (pickup/dropoff). Coordinates models this concept once,
correctly, with validation and the Haversine distance formula built in.
The code — every part explained
// Shared/Domain/ValueObjects/Coordinates.php final class Coordinates { public function __construct( public readonly float $latitude, public readonly float $longitude, ) { // Validate on construction — impossible to have an invalid GPS point if ($latitude < -90.0 || $latitude > 90.0) { throw new InvalidArgumentException("Latitude {$latitude} is out of range [-90, 90]"); } if ($longitude < -180.0 || $longitude > 180.0) { throw new InvalidArgumentException("Longitude {$longitude} is out of range [-180, 180]"); } } // Haversine formula: accurate curved-earth distance in km // Used by: EloquentDriverRepository.findAvailableNear() for dispatch public function distanceKmTo(self $other): float { $earthRadiusKm = 6371.0088; $lat1 = deg2rad($this->latitude); $lat2 = deg2rad($other->latitude); $deltaLat = deg2rad($other->latitude - $this->latitude); $deltaLng = deg2rad($other->longitude - $this->longitude); $a = sin($deltaLat / 2) ** 2 + cos($lat1) * cos($lat2) * sin($deltaLng / 2) ** 2; $c = 2 * atan2(sqrt($a), sqrt(1 - $a)); return $earthRadiusKm * $c; } }Shared/Domain/ValueObjects/Coordinates.php
Real delivery app usage
// Scenario: Find drivers within 3km of Al Fanar Restaurant $restaurantLocation = new Coordinates(29.3759, 47.9774); // Kuwait City $driverLocation = new Coordinates(29.3812, 47.9801); // Ahmed's position $distance = $restaurantLocation->distanceKmTo($driverLocation); // Returns: 0.67 km — Ahmed is only 670 metres away ✅ // Used in: EloquentDriverRepository::findAvailableNear() // Why not just compare raw floats? // Because the Earth is a sphere. The straight-line distance between // lat/lng values is NOT the real-world distance — the Haversine formula // accounts for Earth's curvature. This matters for city-scale navigation. // Why validate range? $bad = new Coordinates(200.0, 47.97); // throws: "Latitude 200.0 is out of range [-90, 90]" // Without this, a mobile app bug could send lat=200 and it would silently // corrupt the driver's location in the database.Coordinates in dispatch
An address is not just a string. It has multiple fields, and in a delivery app it also has
GPS coordinates so drivers can navigate to it. Address bundles all of this into one
safe, structured object.
The code
// Shared/Domain/ValueObjects/Address.php final class Address { public function __construct( public readonly string $line1, public readonly ?string $line2, // optional (apartment number) public readonly string $city, public readonly ?string $state, // optional (not all countries use this) public readonly ?string $postalCode, // optional (Kuwait uses block/area system) public readonly string $country, // ISO code: 'KW', 'AE', 'SA' public readonly Coordinates $coordinates, // GPS embedded! ← uses Shared/Coordinates public readonly ?string $notes = null, // "Blue gate on the left" ) {} // Helper: build a readable string from all parts public function fullAddress(): string { return trim(implode(', ', array_filter([ $this->line1, $this->line2, $this->city, $this->state, $this->postalCode, $this->country, ]))); // Example: "Arabian Gulf Street, Kuwait City, KW" } }Shared/Domain/ValueObjects/Address.php
If you store "Arabian Gulf Street, Kuwait City" as a plain string, you can't:
calculate the distance from a driver to the pickup point (no GPS),
filter deliveries by city (no separate city field),
display it on a map (no lat/lng).
As a Value Object with embedded Coordinates, all of this is automatic.
Where Address is used in your project
$pickup->coordinates->distanceKmTo($driverLocation) — uses both Address and Coordinates.
Money is the most dangerous primitive in any financial application.
Storing it as float causes real money bugs in production.
Money solves this by using integer minor units (cents) for all storage and math.
The float problem — a real bug
// ❌ THE FLOATING POINT TRAP — this is a real problem in production $deliveryFee = 1.10; $platformFee = 0.20; $total = $deliveryFee + $platformFee; var_dump($total); // float(1.2999999999999998) ❌ NOT 1.30! // This is not PHP being bad — ALL computers have this problem. // Floats cannot represent 1.30 exactly in binary. // In a payment system: round(1.2999...) = 1.30 sometimes, 1.29 sometimes. // Multiply by 10,000 orders → significant money discrepancy. // ✅ THE MONEY SOLUTION — integer minor units (fils/cents) $deliveryFee = new Money(110, 'KWD'); // 110 fils = KWD 1.10 $platformFee = new Money(20, 'KWD'); // 20 fils = KWD 0.20 $total = $deliveryFee->add($platformFee); // 130 fils = KWD 1.30 exactly ✅ // Integer addition: 110 + 20 = 130. Always exact. No floating point.
The Money API — all the operations
// Shared/Domain/ValueObjects/Money.php — key methods // Create from major units (human-friendly input) $fee = Money::fromMajor(12.50, 'KWD'); // 12.50 KWD → stored as 1250 fils $tip = Money::fromMajor(2.00, 'KWD'); // 2.00 KWD → stored as 200 fils // Arithmetic — always returns a NEW Money object (immutable) $total = $fee->add($tip); // 1450 fils = KWD 14.50 $discount = $total->multiply(0.9); // 1305 fils = KWD 13.05 (10% off) $refund = $total->subtract($tip); // 1250 fils = KWD 12.50 // Checks $discount->isNegative(); // false Money::zero('KWD')->isZero(); // true // Currency protection — prevents silent bugs $kwdMoney = new Money(1000, 'KWD'); $aedMoney = new Money(1000, 'AED'); $kwdMoney->add($aedMoney); // throws: "Currency mismatch: KWD vs AED" // Cannot accidentally mix Kuwaiti Dinars with UAE Dirhams // Display echo $total->asMajor(); // 14.5 (float for display only) $total->toArray(); // ['amount' => 1450, 'currency' => 'KWD']Money API usage
Where Money is used in your delivery app
$merchant->creditLimit is a Money object — prevents mixing currencies for multi-country merchants.Money — base fee + distance surcharge, always exact.Money — summed across many deliveries without drift.$money->minorUnits (integer) and $money->currency (string) separately. Reconstructed with new Money($row->amount, $row->currency).Both Email and PhoneNumber follow the same pattern: validate once at construction, normalise the format, and become impossible to use incorrectly anywhere in the system.
Email.php
// Shared/Domain/ValueObjects/Email.php final class Email { public readonly string $value; public function __construct(string $value) { // Normalise: trim whitespace + lowercase — "Ahmed@GMAIL.COM " → "ahmed@gmail.com" $trimmed = strtolower(trim($value)); // PHP's built-in validator if (filter_var($trimmed, FILTER_VALIDATE_EMAIL) === false) { throw new InvalidArgumentException("Invalid email: {$value}"); } $this->value = $trimmed; } } // Usage in Merchant registration: $email = new Email('Ahmed@AlFanar.KW'); // ✅ normalised to "ahmed@alfanar.kw" $bad = new Email('not-an-email'); // ❌ throws immediately // Two merchants with same email — are they equal? $a = new Email('Ahmed@Example.com'); $b = new Email('ahmed@example.com'); $a->equals($b); // true ✅ — normalised before comparisonEmail.php
PhoneNumber.php
// Shared/Domain/ValueObjects/PhoneNumber.php final class PhoneNumber { public readonly string $value; public function __construct(string $value) { // Strip spaces, dashes, parentheses: "+965 (2223) 3456" → "+96522233456" $cleaned = preg_replace('/[\s()-]/', '', $value) ?? ''; // E.164 format: optional +, then 7-15 digits starting with 1-9 if (! preg_match('/^\+?[1-9][0-9]{6,14}$/', $cleaned)) { throw new InvalidArgumentException("Invalid phone number: {$value}"); } // Always add + prefix for E.164 standard $this->value = str_starts_with($cleaned, '+') ? $cleaned : '+'.$cleaned; } } // Examples: new PhoneNumber('+96522233456'); // ✅ Kuwait number new PhoneNumber('965 2223 3456'); // ✅ stripped + normalised → "+96522233456" new PhoneNumber('123'); // ❌ too short → InvalidArgumentException new PhoneNumber('abc'); // ❌ not digits → InvalidArgumentException PhoneNumber.php
Once an Email or PhoneNumber object exists, it is guaranteed valid and normalised.
You never need to validate it again — in Repositories, Services, DTOs, or any other layer.
The type system becomes your validator. This is one of the key powers of Value Objects.
This is the adapter that bridges your framework-agnostic Domain Events to Laravel's actual event system. It's one of the most elegant examples of the Dependency Inversion Principle in your entire project.
The code
// Shared/Infrastructure/Bus/LaravelDomainEventBus.php final class LaravelDomainEventBus implements DomainEventBus { // Wraps Laravel's Dispatcher — the engine behind event() and Event::dispatch() public function __construct(private readonly Dispatcher $dispatcher) {} // Dispatches ONE event to Laravel's event system public function dispatch(DomainEvent $event): void { $this->dispatcher->dispatch($event); // Laravel calls all Listeners registered for this event class } // Dispatches ALL events collected from an aggregate after DB commit public function dispatchAll(iterable $events): void { foreach ($events as $event) { $this->dispatcher->dispatch($event); } } }Shared/Infrastructure/Bus/LaravelDomainEventBus.php
The three-layer bridge — visualised
AppService DomainEventBus LaravelDomainEventBus Laravel Dispatcher Listener │ │ │ │ │ │ dispatchAll( │ │ │ │ │ [DriverWentOnline│ │ │ │ │ ]) │ │ │ │ │ ─────────────────→ │ (interface contract) │ │ │ │ │ ─────────────────────→│ │ │ │ │ │ dispatcher->dispatch() │ │ │ │ │ ────────────────────────→│ │ │ │ │ │ call listeners │ │ │ │ │ ───────────────────→│ │ │ │ │ │ send notification │ │ │ │ │ update analytics │ │ │ │ │ store GPS history
The Application Service (AppService) only knows about the DomainEventBus interface.
It never knows that Laravel is involved. If you swapped Laravel for Symfony tomorrow, you'd only
rewrite LaravelDomainEventBus — the Application Service stays identical.
Why this is in Infrastructure and not Domain
Because it uses Illuminate\Contracts\Events\Dispatcher — a Laravel class.
The Domain must never import from Laravel. Infrastructure is specifically the layer that
is allowed to use framework classes. The contract (DomainEventBus) lives in Domain.
The implementation (LaravelDomainEventBus) lives in Infrastructure. They're connected
only by the interface.
The Service Provider is where Laravel is told: "When anyone asks for DomainEventBus,
give them LaravelDomainEventBus." It's the one place where the Domain contract and
its Infrastructure implementation are connected.
The code
// Shared/Infrastructure/Providers/SharedKernelServiceProvider.php final class SharedKernelServiceProvider extends ServiceProvider { public function register(): void { // singleton: create ONE instance and reuse it for the whole request // (vs bind: creates a new instance every time) $this->app->singleton( DomainEventBus::class, // The interface (Domain layer) LaravelDomainEventBus::class, // The implementation (Infrastructure layer) ); // Now anywhere in the app: // new ApproveMerchantKycService($repo, $eventBus) // Laravel injects LaravelDomainEventBus as $eventBus automatically } // No boot() method — nothing to do after registration }SharedKernelServiceProvider.php
Why singleton instead of bind?
// bind: creates new LaravelDomainEventBus every time DomainEventBus is requested
// singleton: creates it ONCE and reuses the same instance
// For an event bus, singleton is correct because:
// - Laravel's Dispatcher is stateful (it has listener registrations)
// - Creating a fresh Dispatcher for each service call would lose all listener bindings
// - One shared instance = all services use the same dispatcher = listeners work ✅
// Other typical singletons:
// - Database connections (expensive to create)
// - Cache clients
// - External API clients
How all providers boot together
// bootstrap/providers.php (Laravel 11) or config/app.php (Laravel 10) return [ // 1. Shared Kernel — MUST be first, other providers depend on it SharedKernelServiceProvider::class, // registers DomainEventBus // 2. Module providers — register their own repository bindings DriverServiceProvider::class, // registers DriverRepository binding, routes MerchantServiceProvider::class, // registers MerchantRepository binding, routes ]; // Order matters: SharedKernel before modules, // because modules' services type-hint DomainEventBus which must be registered first.bootstrap/providers.php
Let's trace a single business action — admin approves a merchant's KYC — through every file in the Shared Kernel and back out to the real world.
POST /api/v1/merchants/{uuid}/kyc/approve hits the server. Laravel authenticates the admin token, routes to MerchantController::approveKyc().new ApproveMerchantKycCommand(merchantUuid: new Uuid($uuid))— uses
Shared/Uuid to validate and wrap the UUID.Then:
$this->kycHandler->handle($command)
ApproveMerchantKycService opens DB::transaction(). Everything inside either all succeeds or all rolls back.$merchant = $this->repository->findByUuid($uuid). The Repository hydrates a Merchant object (which extends Shared/AggregateRoot) from the database row.$merchant->approveKyc() is called. Inside:— Checks:
$this->kycStatus !== KycStatus::Submitted → if so, throws InvalidKycTransitionException (which extends Shared/DomainException).— If OK: sets
$this->kycStatus = KycStatus::Approved— Calls
$this->recordEvent(new MerchantKycApproved($this->id))—
recordEvent() is inherited from Shared/AggregateRoot — adds event to $pendingEvents[]
$this->repository->save($merchant) — Eloquent writes the new kycStatus = 'approved' to the database. Transaction is still open.DB::transaction closure returns successfully. MySQL commits the row. The Merchant is now Approved in the database.$this->eventBus->dispatchAll($merchant->pullDomainEvents())—
pullDomainEvents() from Shared/AggregateRoot: returns [MerchantKycApproved] and clears the list.—
dispatchAll() from Shared/DomainEventBus contract — implemented by Shared/LaravelDomainEventBus.— Calls
$this->dispatcher->dispatch($event) — hands off to Laravel.
MerchantKycApproved:→
SendKycApprovedEmailListener — sends welcome email to merchant.→
UnlockMerchantDashboardListener — enables frontend features.→
LogKycApprovalListener — writes to audit log.Each Listener runs independently. Adding a new reaction never requires changing the Entity or Service.
Uuid — wrapping the merchant UUID from the URL.
AggregateRoot — recordEvent() and pullDomainEvents().
DomainEvent — MerchantKycApproved extends it.
DomainEventBus — interface the Service calls.
LaravelDomainEventBus — actual dispatch implementation.
DomainException — if KYC wasn't Submitted, thrown and caught globally.
That's 6 of the 13 files in Shared — all in one business operation.
POST /api/v1/drivers/me/online { lat: 29.37, lng: 47.97, heading: 90 } 1. Laravel Router └── Matches route → DriverController::goOnline() └── auth:sanctum middleware: validates Bearer token 2. DriverController::goOnline() └── $request->validate(['lat' => 'numeric|between:-90,90', ...]) └── $driverUuid = new Uuid($self->uuid) ← SHARED: Uuid validates └── $coordinates = new Coordinates(29.37, 47.97) ← SHARED: Coordinates validates range └── new GoOnlineCommand($driverUuid, $coordinates, ...) └── $this->onlineHandler->handle($command) 3. GoOnlineHandler └── $this->service->goOnline($cmd->driverUuid, $cmd->location, ...) 4. UpdateDriverStatusService::goOnline() └── DB::transaction(function() { $driver = $repo->findByUuid($driverUuid) 5. Driver::goOnline(DriverLocation) └── $this->status->canGoOnline() → checks DriverStatus enum └── if not allowed → throw InvalidDriverStatusTransition ← SHARED: DomainException subclass → caught by global handler → HTTP 422 └── $this->status = DriverStatus::Online └── $this->lastLocation = $location └── $this->recordEvent(new DriverWentOnline($this->id, $location)) ← SHARED: AggregateRoot::recordEvent() ← DomainEvent: DriverWentOnline extends DomainEvent 6. EloquentDriverRepository::save($driver) └── Flattens Driver entity → DriverModel columns └── $model->save() → MySQL UPDATE drivers SET status='online', lat=... }) ← transaction commits 7. $this->eventBus->dispatchAll($driver->pullDomainEvents()) ← SHARED: AggregateRoot::pullDomainEvents() ← SHARED: DomainEventBus contract ← SHARED: LaravelDomainEventBus::dispatchAll() └── dispatcher->dispatch(DriverWentOnline) └── Listener: UpdateLiveMapListener → broadcasts driver position to frontend └── Listener: LogDriverActivityListener → analytics 8. DriverController └── return response()->json(['data' => ['status' => 'online']], 202)
This example shows how Shared Value Objects flow through a cross-module scenario: a merchant creates a delivery order, and a driver gets assigned.
// Hypothetical Delivery module — uses Shared types from all over // POST /api/v1/orders { "merchant_uuid": "aaa-111", "pickup_address": { "line1": "Al Fanar Mall", "city": "Kuwait City", "country": "KW", "lat": 29.37, "lng": 47.97 }, "dropoff_address": { "line1": "Block 5, Salmiya", "city": "Salmiya", "country": "KW", "lat": 29.33, "lng": 48.08 }, "delivery_fee": { "amount": 1250, "currency": "KWD" } // KWD 12.50 } // Delivery module's Order entity — uses Shared for everything final class Order extends AggregateRoot // ← SHARED { private function __construct( public readonly Uuid $id, // ← SHARED public readonly Uuid $merchantId, // ← SHARED public readonly Address $pickupAddress, // ← SHARED public readonly Address $dropoffAddress, // ← SHARED public readonly Money $deliveryFee, // ← SHARED private OrderStatus $status, // ← Delivery module's own VO private ?Uuid $assignedDriverId, // ← SHARED (just the ID!) ) {} public static function create(Uuid $merchantId, Address $pickup, ...): self { // Check: is distance reasonable? Uses Shared Coordinates $distance = $pickup->coordinates->distanceKmTo($dropoff->coordinates); if ($distance > 50) { throw new DeliveryAreaTooLargeException(/* extends DomainException ← SHARED */); } $order = new self(id: Uuid::generate(), ...); // ← SHARED Uuid $order->recordEvent(new OrderCreated($order->id)); // ← SHARED AggregateRoot return $order; } public function assignDriver(Uuid $driverUuid): void { $this->assignedDriverId = $driverUuid; // just the UUID ← SHARED $this->status = OrderStatus::Assigned; $this->recordEvent(new DriverAssigned($this->id, $driverUuid)); // ← SHARED } } // Key point: Order references Driver only by Uuid — never imports Driver entity // Modules stay isolated. Shared Uuid is the bridge between them.Order entity — uses Shared throughout
// ❌ WRONG: Everything ends up in Shared Shared/Domain/ ├── ValueObjects/ │ ├── KycStatus.php ← Merchant only! Not shared! │ ├── DriverStatus.php ← Driver only! Not shared! │ ├── DeliveryStatus.php ← Delivery only! Not shared! ├── Services/ │ ├── PricingService.php ← Business logic! Wrong layer! │ └── DispatchService.php ← Business logic! Wrong layer!
Impact: All modules become coupled to Shared. Changing any file in Shared potentially breaks all modules. You've lost the isolation DDD was supposed to provide.
// ❌ WRONG: Shared imports from a module namespace DeliveryApp\Shared\Domain; use DeliveryApp\Driver\Domain\Entities\Driver; // ← NEVER! use DeliveryApp\Merchant\Domain\Entities\Merchant; // ← NEVER! // Shared must have ZERO imports from any module. // Modules depend on Shared. Shared depends on nothing.
// ❌ WRONG: Events fired inside the transaction DB::transaction(function() { $merchant->approveKyc(); $this->eventBus->dispatchAll($merchant->pullDomainEvents()); // ← too early! $this->repository->save($merchant); // ← if this fails, events already fired ❌ }); // ✅ CORRECT: Events fired AFTER the transaction closes DB::transaction(function() { $merchant->approveKyc(); $this->repository->save($merchant); // ← save first }); // ← transaction commits here $this->eventBus->dispatchAll($merchant->pullDomainEvents()); // ← now safe ✅
// ❌ WRONG: Saving float to DB $model->delivery_fee = $money->asMajor(); // stores 12.50 → might be 12.4999999 ❌ // ✅ CORRECT: Save integer minor units $model->delivery_fee_amount = $money->minorUnits; // 1250 — exact integer ✅ $model->delivery_fee_currency = $money->currency; // 'KWD' // Reconstruct on load: $money = new Money($model->delivery_fee_amount, $model->delivery_fee_currency);
// ❌ WRONG: Service swallows the business error try { $merchant->approveKyc(); } catch (DomainException $e) { return false; // ← hiding the error! Caller doesn't know what went wrong } // ✅ CORRECT: Let it bubble up to the global handler $merchant->approveKyc(); // If it throws → bubbles to app/Exceptions/Handler.php → HTTP 422 with clear message // The Domain speaks. The Presentation layer translates. Don't intercept in between.
Shared code is used by every module. A bug here breaks everything. Here's how to test each piece, with real examples you can run right now.
Testing Value Objects — fast, no DB needed
// tests/Unit/Shared/ValueObjects/UuidTest.php it('accepts valid UUID v4', function() { $uuid = new Uuid('550e8400-e29b-41d4-a716-446655440000'); expect($uuid->value)->toBe('550e8400-e29b-41d4-a716-446655440000'); }); it('rejects invalid UUID', function() { expect(fn() => new Uuid('not-a-uuid')) ->toThrow(InvalidArgumentException::class); }); it('generates unique UUIDs', function() { $a = Uuid::generate(); $b = Uuid::generate(); expect($a->equals($b))->toBeFalse(); }); // tests/Unit/Shared/ValueObjects/CoordinatesTest.php it('calculates Haversine distance correctly', function() { $kuwaiti = new Coordinates(29.3759, 47.9774); $salmiya = new Coordinates(29.3326, 48.0785); $distance = $kuwaiti->distanceKmTo($salmiya); expect($distance)->toBeGreaterThan(9)->toBeLessThan(12); // ~10.5 km — verified against Google Maps }); it('rejects latitude out of range', function() { expect(fn() => new Coordinates(91.0, 0.0)) ->toThrow(InvalidArgumentException::class, 'out of range'); }); // tests/Unit/Shared/ValueObjects/MoneyTest.php it('adds money without float drift', function() { $a = Money::fromMajor(1.10, 'KWD'); $b = Money::fromMajor(0.20, 'KWD'); $total = $a->add($b); expect($total->minorUnits)->toBe(130); // exact ✅ expect($total->asMajor())->toBe(1.30); // exact ✅ }); it('throws on currency mismatch', function() { $kwd = new Money(1000, 'KWD'); $aed = new Money(1000, 'AED'); expect(fn() => $kwd->add($aed)) ->toThrow(InvalidArgumentException::class, 'Currency mismatch'); });Value Object unit tests (PestPHP)
Testing AggregateRoot event recording
// tests/Unit/Shared/ValueObjects/AggregateRootTest.php it('records and pulls domain events', function() { // Create a concrete Aggregate for testing $aggregate = new class extends AggregateRoot { public function doSomething(): void { $this->recordEvent(new class extends DomainEvent { public function eventName(): string { return 'test.happened'; } public function toArray(): array { return []; } }); } }; $aggregate->doSomething(); $events = $aggregate->pullDomainEvents(); expect($events)->toHaveCount(1); expect($events[0]->eventName())->toBe('test.happened'); // Pull again — list should be cleared expect($aggregate->pullDomainEvents())->toBeEmpty(); }); it('sets occurredAt automatically', function() { $before = new DateTimeImmutable(); $event = new class extends DomainEvent { public function eventName(): string { return 'x'; } public function toArray(): array { return []; } }; expect($event->occurredAt)->toBeGreaterThanOrEqual($before); });AggregateRoot tests
Testing the LaravelDomainEventBus
// tests/Unit/Shared/Infrastructure/LaravelDomainEventBusTest.php it('dispatches event to Laravel dispatcher', function() { $dispatcher = Mockery::mock(Dispatcher::class); $bus = new LaravelDomainEventBus($dispatcher); $event = new class extends DomainEvent { public function eventName(): string { return 'test'; } public function toArray(): array { return []; } }; $dispatcher->shouldReceive('dispatch')->once()->with($event); $bus->dispatch($event); // No real Laravel needed — Mockery replaces the Dispatcher ✅ });Infrastructure test with mock
The Shared Kernel pattern is also how companies prepare for microservice extraction. Here's how it works at scale.
Multiple teams — who owns what?
| What | Who owns it | Change process |
|---|---|---|
Driver module | Driver team | Team decides alone — their bounded context |
Merchant module | Merchant team | Team decides alone — their bounded context |
Shared kernel | All teams jointly | Any change requires agreement from ALL teams who depend on it — because a breaking change breaks everyone |
Shared Kernel in a future microservice world
// Today: monolith — all modules in one codebase src/ ├── Shared/ ← shared kernel, one codebase ├── Driver/ ← driver module ├── Merchant/ ← merchant module └── Delivery/ ← delivery module // Future: microservices — Shared becomes a library composer require deliveryapp/shared-kernel ← published as package // driver-service/ (separate repo, separate deploy) require deliveryapp/shared-kernel ← uses Uuid, Coordinates, DomainEvent etc. // merchant-service/ (separate repo, separate deploy) require deliveryapp/shared-kernel ← same classes, consistent types // This works because: // 1. Shared has NO dependencies on modules // 2. Shared contains ONLY structural/technical code // 3. Money, Uuid, Coordinates mean the same thing across all services
The warning about Shared in microservices
In a microservice world, changing Money (e.g. adding a new constructor parameter)
requires updating and redeploying ALL services that depend on the shared kernel package simultaneously.
This is called temporal coupling. The solution: keep the Shared Kernel small and change it rarely.
Value Objects like Uuid, Money, Coordinates are stable and rarely need to change —
that's exactly why they belong in Shared.
Final summary — every Shared file and its purpose
| File | Layer | Purpose | Used by |
|---|---|---|---|
DomainEventBus | Contract | Interface for firing events | Every Application Service |
DomainEvent | Domain | Base class for all events | Every module's events |
DomainException | Domain | Marker for business rule violations → HTTP 422 | Every module's exceptions |
AggregateRoot | Domain | Event recording and pulling | Driver, Merchant, Order aggregates |
Uuid | Domain | Validated entity identity standard | Every entity, every command, every repository |
Coordinates | Domain | GPS + Haversine distance | DriverLocation, Address, dispatch queries |
Address | Domain | Structured address with embedded GPS | Merchant defaults, Delivery pickup/dropoff |
Money | Domain | Float-safe currency math | Merchant credit, delivery fees, driver pay |
Email | Domain | Validated, normalised email | Merchant contact, Driver contact, User |
PhoneNumber | Domain | E.164 phone normalisation | Merchant contact, Driver contact, Customer |
LaravelDomainEventBus | Infrastructure | Bridges DomainEventBus → Laravel dispatcher | Laravel DI container (injected by provider) |
SharedKernelServiceProvider | Infrastructure | Registers DomainEventBus → LaravelDomainEventBus | bootstrap/providers.php |
Shared/Application/ | Application | Reserved for shared pagination, sorting helpers | Future Queries across all modules |
The Shared Kernel is not about sharing code to avoid typing. It's about establishing a
common language — a set of concepts that mean exactly the same thing everywhere in your system.
A Uuid is always a Uuid. A Money value is always safe.
A DomainEvent always carries a timestamp and a name.
When you follow this pattern, the modules of your system can grow independently — but they still speak the same language. That's the whole point of DDD.