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)
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.
- 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.
- Implement
ShouldQueuefor 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?
- ❌ Direct Service Call (Tight Coupling)
- ✅ Event-Driven (Loose Coupling)
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);
}
}
- OrderService knows about all dependent modules
- Adding new functionality requires modifying OrderService
- Cannot test OrderService without all dependencies
- Violates Open/Closed Principle
class OrderService
{
// No dependencies on other modules!
public function completeOrder(int $orderId): void
{
// Complete order logic
$order = $this->orderRepository->find($orderId);
$order->status = 'completed';
$order->save();
// Fire event - don't care who listens
event(new OrderCompletedEvent(
orderId: $orderId,
userId: $order->user_id,
totalAmount: $order->total_amount,
));
}
}
- OrderService has zero dependencies on other modules
- New listeners can be added without changing OrderService
- Easy to test OrderService in isolation
- Follows Open/Closed Principle
Event Flow
Step-by-Step Implementation
Step 1: Create Business Event
<?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,
) {
}
}
- Implements
Eventmarker 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
finalto prevent inheritance
Step 2: Fire Event from Service
<?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;
}
}
}
- 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
<?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.
}
}
- Implements
ShouldQueuefor 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
<?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
{
//
}
}
- Map events to listeners in
$listenarray - 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
<?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;
});
}
}
- 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
- Simple Event (No Return)
- Event with Simple Return
- Multiple Listeners
- Conditional Event Firing
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 for: When you need confirmation but not complex data.
class PaymentService
{
public function processPayment(int $orderId, int $amount): bool
{
try {
// Process payment
$payment = $this->gateway->charge($amount);
// Fire event
event(new PaymentProcessedEvent(
orderId: $orderId,
paymentId: $payment->id,
amount: $amount,
));
return true; // Simple boolean return
} catch (\Exception $e) {
return false;
}
}
}
Best for: When multiple modules need to react to the same business event.
// Event
class OrderCompletedEvent implements Event
{
public function __construct(
public readonly int $orderId,
public readonly int $userId,
public readonly int $totalAmount,
) {}
}
// Listener 1: Create Invoice
class CreateInvoiceOnOrderCompletion implements ShouldQueue
{
public function handle(OrderCompletedEvent $event): void
{
$this->invoiceService->createInvoice($event->orderId);
}
}
// Listener 2: Send Email
class SendOrderCompletionEmail implements ShouldQueue
{
public function handle(OrderCompletedEvent $event): void
{
$this->emailService->sendOrderEmail($event->orderId);
}
}
// Listener 3: Update Analytics
class TrackOrderCompletion implements ShouldQueue
{
public function handle(OrderCompletedEvent $event): void
{
$this->analyticsService->track($event->orderId);
}
}
// Registration
protected $listen = [
OrderCompletedEvent::class => [
CreateInvoiceOnOrderCompletion::class,
SendOrderCompletionEmail::class,
TrackOrderCompletion::class,
],
];
Best for: When events should only fire under certain conditions.
class OrderService
{
public function updateOrderStatus(int $orderId, string $status): bool
{
$order = $this->repository->findOrFail($orderId);
$oldStatus = $order->status;
$order->status = $status;
$order->save();
// Fire event only when transitioning to completed
if ($oldStatus !== 'completed' && $status === 'completed') {
event(new OrderCompletedEvent(
orderId: $order->id,
userId: $order->user_id,
totalAmount: $order->total_amount,
));
}
return true;
}
}
Best Practices
1. Event Naming
- ✅ DO
- ❌ DON'T
// Past tense - describes what happened
class OrderCompletedEvent implements Event {}
class UserRegisteredEvent implements Event {}
class PaymentProcessedEvent implements Event {}
class InvoiceGeneratedEvent implements Event {}
// Present tense or imperative
class CompleteOrderEvent implements Event {}
class RegisterUserEvent implements Event {}
class ProcessPayment implements Event {}
2. Event Data
- ✅ DO
- ❌ DON'T
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
) {}
}
class OrderCompletedEvent implements Event
{
public function __construct(
public Order $order, // Entities
public User $user, // Entities
public Collection $items, // Complex objects
) {}
}
3. Listener Responsibilities
- ✅ DO
- ❌ DON'T
class CreateInvoiceListener implements ShouldQueue
{
public function handle(OrderCompletedEvent $event): void
{
// Thin orchestrator - delegate to service
$this->invoiceService->createInvoice($event->orderId);
}
}
class CreateInvoiceListener implements ShouldQueue
{
public function handle(OrderCompletedEvent $event): void
{
// Complex business logic in listener
$order = Order::find($event->orderId);
$user = User::find($event->userId);
$invoice = new Invoice();
$invoice->order_id = $order->id;
$invoice->user_id = $user->id;
// ... 50 more lines of business logic
$invoice->save();
}
}
4. Error Handling
- ✅ DO
- ❌ DON'T
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,
]);
}
}
class ProcessPaymentListener implements ShouldQueue
{
public function handle(OrderCompletedEvent $event): void
{
try {
$this->paymentService->process($event->orderId);
} catch (\Exception $e) {
// Silently swallow exception
// No logging, no retry, no notification
}
}
}
5. Synchronous vs Asynchronous
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
- ✅ DO - Specific Events
- ❌ DON'T - Generic Events
class OrderCompletedEvent implements Event {}
class OrderCancelledEvent implements Event {}
class OrderRefundedEvent implements Event {}
Why: Specific events are easier to understand and maintain.
class OrderStatusChangedEvent implements Event
{
public function __construct(
public readonly int $orderId,
public readonly string $oldStatus,
public readonly string $newStatus,
) {}
}
Why: Generic events require conditional logic in listeners.
Testing Strategies
Unit Testing Listeners
<?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
<?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
- Event fires but listener doesn't run
- No errors in logs
Solutions
1. Check Registration:
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
- Listener executes multiple times for single event
- Duplicate invoices/emails created
Solutions
1. Check for Multiple Dispatches:
- ❌ Wrong
- ✅ Correct
// 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!
}
// Fire once
public function completeOrder(int $orderId): void
{
$order = $this->repository->find($orderId);
$order->status = 'completed';
$order->save();
event(new OrderCompletedEvent($orderId)); // Once
}
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
- Slow event processing
- Queue backlog growing
- High memory usage
Solutions
1. Optimize Listener:
- ❌ Slow
- ✅ Fast
// Loads all data
class ProcessOrderListener implements ShouldQueue
{
public function handle(OrderCompletedEvent $event): void
{
$order = Order::with('items', 'user', 'payments')->find($event->orderId);
// Process...
}
}
// Loads only needed data
class ProcessOrderListener implements ShouldQueue
{
public function handle(OrderCompletedEvent $event): void
{
// Event already has needed data
$this->service->process(
$event->orderId,
$event->totalAmount
);
}
}
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:
[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:
- ✅ Achieve true module independence
- ✅ Enable asynchronous processing
- ✅ Support complex workflows
- ✅ Maintain system observability
- ✅ Scale horizontally with queues
Key Takeaways
- Use Events for Write Operations: Commands/events change state, queries read data
- Keep Events Simple: IDs and primitives only, no entities
- Listeners are Orchestrators: Delegate to services, don't contain business logic
- Async by Default: Use
ShouldQueueunless you have a specific reason not to - Handle Failures: Implement retry logic and
failed()methods - Log Everything: Comprehensive logging for debugging and monitoring
- Test Thoroughly: Unit test listeners, integration test event flow
Events represent what happened, not what should happen.