Skip to main content

Command Infrastructure: Implementation Guide

Overview

The Command Infrastructure provides a standardized way for modules to trigger operations or change state in other modules without creating tight coupling. Unlike queries (which read data), commands use an event-driven architecture to maintain loose coupling.

Core Principle

Event-Driven Architecture: Modules communicate by publishing events rather than calling each other's services directly. This creates a publish-subscribe pattern where publishers don't know who (if anyone) is listening.

Command Query Separation (CQS)

Separation of Concerns

The system strictly separates:

  • Queries: Read data, return complex DTOs, synchronous
  • Commands/Events: Change state, return minimal data (void/ID/boolean), asynchronous

Architecture Components

1. Business Events

Events represent meaningful business occurrences that other modules might care about.

Event Characteristics
  • Immutable (all properties readonly)
  • Contain minimal data (IDs, not full entities)
  • Named in past tense (OrderCompleted, UserRegistered)
  • Implement marker interface for type safety

2. Event Listeners

Listeners react to events and delegate work to services.

Listener Characteristics
  • Implement ShouldQueue for async processing
  • Thin orchestrators (delegate to services)
  • No complex business logic
  • Handle one event type

3. Event Bus

Laravel's event system acts as the message bus.

Responsibilities:

  • Route events to registered listeners
  • Handle queuing for async listeners
  • Provide event lifecycle hooks

Event-Driven Communication

Why Events Instead of Direct Calls?

OrderService.php - Tightly Coupled
class OrderService
{
public function __construct(
private InvoiceService $invoiceService, // Direct dependency
private NotificationService $notificationService, // Direct dependency
private AnalyticsService $analyticsService, // Direct dependency
) {}

public function completeOrder(int $orderId): void
{
// Complete order logic
$order = $this->orderRepository->find($orderId);
$order->status = 'completed';
$order->save();

// Tightly coupled to other modules
$this->invoiceService->createInvoice($orderId);
$this->notificationService->sendOrderEmail($orderId);
$this->analyticsService->trackOrderCompletion($orderId);
}
}
Problems
  • OrderService knows about all dependent modules
  • Adding new functionality requires modifying OrderService
  • Cannot test OrderService without all dependencies
  • Violates Open/Closed Principle

Event Flow


Step-by-Step Implementation

Step 1: Create Business Event

app/Events/OrderCompletedEvent.php
<?php

declare(strict_types=1);

namespace App\Events;

use App\Contracts\Event;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

final class OrderCompletedEvent implements Event
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;

public function __construct(
public readonly int $orderId,
public readonly int $userId,
public readonly int $totalAmount,
public readonly string $orderNumber,
) {
}
}
Key Points
  • Implements Event marker interface
  • All properties are public readonly (immutable)
  • Uses Laravel event traits
  • Contains only essential data (IDs and primitives)
  • Named in past tense (describes what happened)
  • Use final to prevent inheritance

Step 2: Fire Event from Service

app/Modules/Orders/Services/OrderService.php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Services;

use App\Events\OrderCompletedEvent;
use App\Modules\Orders\Repositories\OrderRepository;

final class OrderService
{
public function __construct(
private readonly OrderRepository $repository
) {
}

public function completeOrder(int $orderId): bool
{
try {
$order = $this->repository->findOrFail($orderId);

// Perform business operation
$order->status = 'completed';
$order->completed_at = now();
$order->save();

// highlight-start
// Fire event after successful operation
event(new OrderCompletedEvent(
orderId: $order->id,
userId: $order->user_id,
totalAmount: $order->total_amount,
orderNumber: $order->order_number,
));
// highlight-end

return true;
} catch (\Exception $e) {
// Log error
logger()->error('Failed to complete order', [
'order_id' => $orderId,
'exception' => $e->getMessage(),
]);

return false;
}
}
}
Important
  • Fire event after successful operation
  • Include only necessary data in event
  • Don't fire events in transactions (fire after commit)
  • Use event() helper function

Step 3: Create Event Listener

app/Modules/Invoicing/Listeners/CreateInvoiceOnOrderCompletion.php
<?php

declare(strict_types=1);

namespace App\Modules\Invoicing\Listeners;

use App\Events\OrderCompletedEvent;
use App\Modules\Invoicing\Services\InvoiceService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;

final class CreateInvoiceOnOrderCompletion implements ShouldQueue
{
use InteractsWithQueue;

/**
* The number of times the job may be attempted.
*/
public int $tries = 3;

/**
* The number of seconds to wait before retrying.
*/
public int $backoff = 60;

public function __construct(
private readonly InvoiceService $invoiceService
) {
}

public function handle(OrderCompletedEvent $event): void
{
try {
// Delegate to service layer
$this->invoiceService->createInvoiceForOrder(
orderId: $event->orderId,
userId: $event->userId,
totalAmount: $event->totalAmount,
);

logger()->info('Invoice created for order', [
'order_id' => $event->orderId,
'order_number' => $event->orderNumber,
]);
} catch (\Exception $e) {
logger()->error('Failed to create invoice', [
'order_id' => $event->orderId,
'exception' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);

// Re-throw to trigger retry mechanism
throw $e;
}
}

/**
* Handle a job failure.
*/
public function failed(OrderCompletedEvent $event, \Throwable $exception): void
{
logger()->critical('Invoice creation failed after all retries', [
'order_id' => $event->orderId,
'exception' => $exception->getMessage(),
]);

// Could notify admin, create alert, etc.
}
}
Key Points
  • Implements ShouldQueue for async processing
  • Delegates to service layer (thin orchestrator)
  • Configures retry logic ($tries, $backoff)
  • Comprehensive error logging
  • Implements failed() method for final failure handling
  • Uses dependency injection for services

Step 4: Register Listener

app/Providers/EventServiceProvider.php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Events\OrderCompletedEvent;
use App\Modules\Invoicing\Listeners\CreateInvoiceOnOrderCompletion;
use App\Modules\Notifications\Listeners\SendOrderCompletionEmail;
use App\Modules\Analytics\Listeners\TrackOrderCompletion;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

class EventServiceProvider extends ServiceProvider
{
/**
* The event listener mappings for the application.
*/
protected $listen = [
OrderCompletedEvent::class => [
CreateInvoiceOnOrderCompletion::class,
SendOrderCompletionEmail::class,
TrackOrderCompletion::class,
],

// Other event mappings...
];

/**
* Register any events for your application.
*/
public function boot(): void
{
//
}
}
Registration Notes
  • Map events to listeners in $listen array
  • Multiple listeners can subscribe to same event
  • Order of listeners in array doesn't guarantee execution order (async)
  • Can use event discovery instead of manual registration

Step 5: Create Service Layer

app/Modules/Invoicing/Services/InvoiceService.php
<?php

declare(strict_types=1);

namespace App\Modules\Invoicing\Services;

use App\Domains\Order\OrderQuery;
use App\Domains\Order\OrderId;
use App\Domains\User\UserQuery;
use App\Domains\User\UserId;
use App\Modules\Invoicing\Repositories\InvoiceRepository;
use Illuminate\Support\Facades\DB;

final class InvoiceService
{
public function __construct(
private readonly InvoiceRepository $repository,
private readonly OrderQuery $orderQuery,
private readonly UserQuery $userQuery,
) {
}

public function createInvoiceForOrder(
int $orderId,
int $userId,
int $totalAmount,
): int {
// Get additional data using queries
$order = $this->orderQuery->getById(new OrderId($orderId));
$user = $this->userQuery->getById(new UserId($userId));

// Create invoice in transaction
return DB::transaction(function () use ($order, $user, $totalAmount) {
$invoice = $this->repository->create([
'order_id' => $order->id->value,
'user_id' => $user->id->value,
'order_number' => $order->orderNumber,
'customer_name' => $user->fullName,
'total_amount' => $totalAmount,
'status' => 'pending',
'created_at' => now(),
]);

return $invoice->id;
});
}
}
Key Points
  • Uses Query interfaces to get additional data
  • Contains actual business logic
  • Uses transactions for data integrity
  • Returns simple data (ID, boolean, etc.)
  • No knowledge of events

Usage Patterns

Best for: Fire-and-forget operations where you don't need confirmation.

class UserService
{
public function registerUser(array $data): int
{
$user = $this->repository->create($data);

// Fire event - don't wait for result
event(new UserRegisteredEvent(
userId: $user->id,
email: $user->email,
));

return $user->id;
}
}

Best Practices

1. Event Naming

// Past tense - describes what happened
class OrderCompletedEvent implements Event {}
class UserRegisteredEvent implements Event {}
class PaymentProcessedEvent implements Event {}
class InvoiceGeneratedEvent implements Event {}

2. Event Data

class OrderCompletedEvent implements Event
{
public function __construct(
public readonly int $orderId, // IDs
public readonly int $userId, // IDs
public readonly int $totalAmount, // Primitives
public readonly string $orderNumber, // Primitives
) {}
}

3. Listener Responsibilities

class CreateInvoiceListener implements ShouldQueue
{
public function handle(OrderCompletedEvent $event): void
{
// Thin orchestrator - delegate to service
$this->invoiceService->createInvoice($event->orderId);
}
}

4. Error Handling

class ProcessPaymentListener implements ShouldQueue
{
public int $tries = 3;
public int $backoff = 60;

public function handle(OrderCompletedEvent $event): void
{
try {
$this->paymentService->process($event->orderId);
} catch (\Exception $e) {
logger()->error('Payment processing failed', [
'order_id' => $event->orderId,
'exception' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);

throw $e; // Re-throw for retry
}
}

public function failed(OrderCompletedEvent $event, \Throwable $exception): void
{
// Handle final failure
logger()->critical('Payment failed after all retries', [
'order_id' => $event->orderId,
]);
}
}

5. Synchronous vs Asynchronous

When to Use Each

Asynchronous (Recommended):

class SendEmailListener implements ShouldQueue  // Async
{
public function handle(UserRegisteredEvent $event): void
{
$this->emailService->sendWelcomeEmail($event->userId);
}
}

Use async when:

  • Email sending
  • External API calls
  • Heavy processing
  • Non-critical operations
  • Most use cases

Synchronous (Use Sparingly):

class UpdateCacheListener  // Sync - no ShouldQueue
{
public function handle(ProductUpdatedEvent $event): void
{
// Must happen immediately
Cache::forget("product:{$event->productId}");
}
}

Use sync when:

  • Cache invalidation
  • Critical data consistency
  • Must complete before response
  • Very fast operations (<100ms)

6. Event Granularity

class OrderCompletedEvent implements Event {}
class OrderCancelledEvent implements Event {}
class OrderRefundedEvent implements Event {}

Why: Specific events are easier to understand and maintain.


Testing Strategies

Unit Testing Listeners

tests/Unit/Modules/Invoicing/Listeners/CreateInvoiceOnOrderCompletionTest.php
<?php

namespace Tests\Unit\Modules\Invoicing\Listeners;

use App\Events\OrderCompletedEvent;
use App\Modules\Invoicing\Listeners\CreateInvoiceOnOrderCompletion;
use App\Modules\Invoicing\Services\InvoiceService;
use Mockery;
use Tests\TestCase;

final class CreateInvoiceOnOrderCompletionTest extends TestCase
{
public function test_creates_invoice_when_order_completed(): void
{
// Arrange
$event = new OrderCompletedEvent(
orderId: 1,
userId: 10,
totalAmount: 10000,
orderNumber: 'ORD-001',
);

$invoiceService = Mockery::mock(InvoiceService::class);
$invoiceService->shouldReceive('createInvoiceForOrder')
->once()
->with(1, 10, 10000)
->andReturn(100); // Invoice ID

$listener = new CreateInvoiceOnOrderCompletion($invoiceService);

// Act
$listener->handle($event);

// Assert - verified by Mockery expectations
}

public function test_logs_error_on_failure(): void
{
// Arrange
$event = new OrderCompletedEvent(
orderId: 1,
userId: 10,
totalAmount: 10000,
orderNumber: 'ORD-001',
);

$invoiceService = Mockery::mock(InvoiceService::class);
$invoiceService->shouldReceive('createInvoiceForOrder')
->once()
->andThrow(new \Exception('Database error'));

$listener = new CreateInvoiceOnOrderCompletion($invoiceService);

// Act & Assert
$this->expectException(\Exception::class);
$listener->handle($event);
}
}

Integration Testing Events

tests/Integration/Events/OrderCompletedEventTest.php
<?php

namespace Tests\Integration\Events;

use App\Events\OrderCompletedEvent;
use App\Modules\Invoicing\Listeners\CreateInvoiceOnOrderCompletion;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;

final class OrderCompletedEventTest extends TestCase
{
use RefreshDatabase;

public function test_event_dispatches_to_listeners(): void
{
// Fake events to capture them
Event::fake([OrderCompletedEvent::class]);

// Trigger the event
event(new OrderCompletedEvent(
orderId: 1,
userId: 10,
totalAmount: 10000,
orderNumber: 'ORD-001',
));

// Assert event was dispatched
Event::assertDispatched(OrderCompletedEvent::class, function ($event) {
return $event->orderId === 1
&& $event->userId === 10
&& $event->totalAmount === 10000;
});
}

public function test_listener_is_queued(): void
{
// Fake queue
Queue::fake();

// Dispatch event
event(new OrderCompletedEvent(
orderId: 1,
userId: 10,
totalAmount: 10000,
orderNumber: 'ORD-001',
));

// Assert listener was queued
Queue::assertPushed(CreateInvoiceOnOrderCompletion::class);
}
}

Common Patterns and Examples

Pattern: Saga/Workflow Coordination

Use Case: Multi-step process across modules.

View Implementation Code
// Step 1: Order completed
class OrderService
{
public function completeOrder(int $orderId): void
{
$order = $this->repository->findOrFail($orderId);
$order->status = 'completed';
$order->save();

event(new OrderCompletedEvent($orderId));
}
}

// Step 2: Invoice created
class CreateInvoiceListener implements ShouldQueue
{
public function handle(OrderCompletedEvent $event): void
{
$invoiceId = $this->invoiceService->create($event->orderId);

event(new InvoiceCreatedEvent($invoiceId, $event->orderId));
}
}

// Step 3: Payment processed
class ProcessPaymentListener implements ShouldQueue
{
public function handle(InvoiceCreatedEvent $event): void
{
$paymentId = $this->paymentService->process($event->invoiceId);

event(new PaymentProcessedEvent($paymentId, $event->invoiceId));
}
}

// Step 4: Send confirmation
class SendConfirmationListener implements ShouldQueue
{
public function handle(PaymentProcessedEvent $event): void
{
$this->emailService->sendConfirmation($event->paymentId);
}
}

Pattern: Compensating Transactions

Use Case: Rollback on failure.

class PaymentProcessedEvent implements Event
{
public function __construct(
public readonly int $paymentId,
public readonly int $orderId,
public readonly bool $success,
) {}
}

class RollbackOrderOnPaymentFailure implements ShouldQueue
{
public function handle(PaymentProcessedEvent $event): void
{
if (!$event->success) {
// Compensating transaction
$this->orderService->revertToPending($event->orderId);

logger()->warning('Order reverted due to payment failure', [
'order_id' => $event->orderId,
'payment_id' => $event->paymentId,
]);
}
}
}

Pattern: Event Versioning

Use Case: Maintain backward compatibility.

// V1 Event
class OrderCompletedEventV1 implements Event
{
public function __construct(
public readonly int $orderId,
public readonly int $totalAmount,
) {}
}

// V2 Event (with additional data)
class OrderCompletedEventV2 implements Event
{
public function __construct(
public readonly int $orderId,
public readonly int $userId,
public readonly int $totalAmount,
public readonly string $orderNumber,
) {}
}

// Adapter listener
class OrderEventAdapter implements ShouldQueue
{
public function handle(OrderCompletedEventV1 $event): void
{
// Convert V1 to V2
$order = $this->orderQuery->getById(new OrderId($event->orderId));

event(new OrderCompletedEventV2(
orderId: $event->orderId,
userId: $order->userId->value,
totalAmount: $event->totalAmount,
orderNumber: $order->orderNumber,
));
}
}

Troubleshooting

Issue: Listener Not Executing

Symptoms
  • Event fires but listener doesn't run
  • No errors in logs
Solutions

1. Check Registration:

EventServiceProvider.php
protected $listen = [
OrderCompletedEvent::class => [
CreateInvoiceListener::class, // Ensure registered
],
];

2. Clear Event Cache:

php artisan event:clear
php artisan cache:clear
php artisan config:clear

3. Check Queue Worker:

# If listener implements ShouldQueue
php artisan queue:work

# Check failed jobs
php artisan queue:failed

Issue: Event Fired Multiple Times

Symptoms
  • Listener executes multiple times for single event
  • Duplicate invoices/emails created
Solutions

1. Check for Multiple Dispatches:

// Fires multiple times
public function completeOrder(int $orderId): void
{
$order = $this->repository->find($orderId);
event(new OrderCompletedEvent($orderId)); // First

$order->status = 'completed';
$order->save();

event(new OrderCompletedEvent($orderId)); // Second - duplicate!
}

2. Implement Idempotency:

class CreateInvoiceListener implements ShouldQueue
{
public function handle(OrderCompletedEvent $event): void
{
// Check if invoice already exists
if ($this->invoiceRepository->existsForOrder($event->orderId)) {
logger()->info('Invoice already exists, skipping', [
'order_id' => $event->orderId,
]);
return;
}

$this->invoiceService->create($event->orderId);
}
}

Issue: Performance Problems

Symptoms
  • Slow event processing
  • Queue backlog growing
  • High memory usage
Solutions

1. Optimize Listener:

// Loads all data
class ProcessOrderListener implements ShouldQueue
{
public function handle(OrderCompletedEvent $event): void
{
$order = Order::with('items', 'user', 'payments')->find($event->orderId);
// Process...
}
}

2. Use Chunking for Batch Operations:

class ProcessBulkOrdersListener implements ShouldQueue
{
public function handle(BulkOrdersCompletedEvent $event): void
{
// Process in chunks
collect($event->orderIds)
->chunk(100)
->each(function ($chunk) {
$this->service->processBatch($chunk->toArray());
});
}
}

3. Increase Queue Workers:

Supervisor Configuration
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /path/to/artisan queue:work --sleep=3 --tries=3
autostart=true
autorestart=true
numprocs=8 # Increase number of workers

Conclusion

The Command/Event Infrastructure provides a robust way to trigger operations across module boundaries while maintaining loose coupling. By following this implementation guide and best practices, you can:

Achievements
  • ✅ Achieve true module independence
  • ✅ Enable asynchronous processing
  • ✅ Support complex workflows
  • ✅ Maintain system observability
  • ✅ Scale horizontally with queues

Key Takeaways

  1. Use Events for Write Operations: Commands/events change state, queries read data
  2. Keep Events Simple: IDs and primitives only, no entities
  3. Listeners are Orchestrators: Delegate to services, don't contain business logic
  4. Async by Default: Use ShouldQueue unless you have a specific reason not to
  5. Handle Failures: Implement retry logic and failed() methods
  6. Log Everything: Comprehensive logging for debugging and monitoring
  7. Test Thoroughly: Unit test listeners, integration test event flow
Remember

Events represent what happened, not what should happen.