Event-Driven Architecture: Decoupling Laravel Domain Modules
Master event-driven architecture in Laravel to decouple your domain modules. Learn to dispatch events and register listeners for scalable, maintainable code.
Previously in this course, we discussed the Modular Monolith Structure, where we organized our application into distinct domain-specific directories. While this structure physicalized our boundaries, it didn't solve the problem of cross-module dependencies.
In this lesson, we move from direct service-to-service coupling toward an event-driven architecture. By using Laravel’s internal event bus, we can allow our modules to communicate without needing to know about each other's internal implementation details.
The Problem: Tight Coupling Across Domains
When you build a SaaS platform, domains naturally overlap. For example, when a User completes a purchase in your Billing module, the Marketing module might need to send a welcome email, and the Analytics module might need to log the transaction.
If your BillingService directly calls MarketingService and AnalyticsService, you've created a "distributed big ball of mud." If you decide to swap your email provider or change how analytics are tracked, you have to modify the Billing module—a clear violation of the Single Responsibility Principle.
Decoupling with Events
Instead of direct calls, the Billing module should simply broadcast a fact: "An order was completed." Any other module that cares about that fact can listen for it independently.
Flow diagram: Billing Module → Dispatches Event Bus; Event Bus → Marketing Module; Event Bus → Analytics Module
Dispatching Events Across Boundaries
In a domain-driven system, we define events as "Domain Events." These represent something that happened in the past. To keep our code clean, we define these events within the domain that owns the logic.
Let's assume we are in our Billing module. We define a OrderCompleted event:
PHPnamespace Modules\Billing\Events; use Modules\Billing\Models\Order; readonly class OrderCompleted { public function __construct( public Order $order ) {} }
Now, in our CheckoutAction (referencing our work on Implementing Action Classes), we dispatch this event after the database transaction succeeds:
PHP#6A9955">// Inside Modules\Billing\Actions\CheckoutAction.php public function execute(array $data): Order { return DB::transaction(function () use ($data) { $order = Order::create($data); #6A9955">// Dispatch the event event(new \Modules\Billing\Events\OrderCompleted($order)); return $order; }); }
Notice that CheckoutAction knows nothing about who is listening. It only knows that an order was completed.
Registering Listeners in Providers
To wire these components together, we use the EventServiceProvider within our specific module. This keeps the configuration localized.
In your Modules/Marketing/Providers/EventServiceProvider.php:
PHPnamespace Modules\Marketing\Providers; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use Modules\Billing\Events\OrderCompleted; use Modules\Marketing\Listeners\SendWelcomeEmail; class EventServiceProvider extends ServiceProvider { protected $listen = [ OrderCompleted::class => [ SendWelcomeEmail::class, ], ]; }
By registering the listener here, we maintain a clear separation. The Marketing module "subscribes" to the Billing module's event. If we decide to remove the Marketing module entirely, we simply delete its provider, and the Billing module continues to function perfectly.
Advanced Usage: Queued Listeners
In high-traffic systems, you should never perform blocking operations (like sending emails or hitting third-party APIs) inside the main request cycle. Laravel makes it trivial to offload these to the queue by implementing the ShouldQueue interface on your listener.
PHPnamespace Modules\Marketing\Listeners; use Illuminate\Contracts\Queue\ShouldQueue; use Modules\Billing\Events\OrderCompleted; class SendWelcomeEmail implements ShouldQueue { public function handle(OrderCompleted $event): void { #6A9955">// This will now run in the background } }
Hands-on Exercise: Decoupling User Registration
- Identify a cross-module dependency: Look at your current user registration logic. Is it directly triggering a "Welcome Notification" or "Add to CRM" service?
- Create an Event: Define a
UserRegisteredevent inside yourUsermodule. - Dispatch: Update your
RegisterUserActionto dispatch this event. - Register a Listener: Create a new listener in your
Notificationmodule that catchesUserRegisteredand sends the welcome email. - Verify: Ensure that the
Usermodule has nousestatements referencing theNotificationnamespace.
Common Pitfalls
- Passing Models vs. IDs: Avoid passing entire Eloquent models in events if you are queueing them. If the model changes between the event dispatch and the job execution, you might have stale data. Pass the ID and refetch the model in the listener.
- Event Loops: Be careful not to dispatch an event in a listener that triggers the same event, causing an infinite loop.
- Over-Engineering: Don't turn every method call into an event. Use events for cross-boundary communication. If two classes live in the same module and are tightly related, a direct service call is often more readable and easier to debug.
Recap
Event-driven architecture is the glue that holds a modular monolith together. By dispatching events from your domain actions and registering listeners in service providers, you effectively decouple your services. This allows your SaaS platform to evolve, as modules can be added, removed, or refactored without triggering a cascade of breaking changes.
Up next: Integrating External Message Brokers — moving beyond the internal bus to distributed systems.
Work with me

Laravel REST API Development
Clean, secure, well-documented Laravel REST APIs — the backend engine for your app, mobile client, or SaaS. Built by an API specialist.

FilamentPHP Admin Panel & Dashboard Development
A powerful admin panel for your Laravel app — built with FilamentPHP so you can manage everything without touching the database.