DEV Community

Cover image for What You Really Need Is Event-Based Laravel
Raheel Shan
Raheel Shan

Posted on • Originally published at raheelshan.com

What You Really Need Is Event-Based Laravel

Many times while building Laravel applications, I ran into situations where a single process required handling multiple tasks. At first, I wrote everything inside service classes, but those classes quickly became too long and messy. Everything ended up wrapped inside one big transaction, and testing became painful because all the logic was tightly packed into the same place. That’s when I realized I needed a better approach. So I started breaking methods into separated classes. This approach was much better but when going through the Laravel Docs I found a better solution if used efficiently, our code becomes cleaner, more maintainable, and far easier to test.

Laravel Events

When you need to run a series of actions to complete a process, Laravel Events are the right tool. An event can trigger multiple listeners, and each listener is an independent class that doesn’t depend on the others. This keeps the workflow smooth, decoupled, and easy to extend. Instead of cramming everything into one oversized service class, you can break tasks into small, testable units that can be added, removed, or modified without touching the rest. By using events effectively, each part of your process remains clean, isolated, and much easier to manage as your application grows.

Example: E-Commerce – Store Initialization

Let’s say you’re building an e-commerce SaaS platform. A user comes in, registers, and creates their store.

When user presses Save button the following happens behind the scene.

  1. Save the store.
  2. Assign store to current authenticated user.
  3. Assign admin role to the user.
  4. Assign default permissions to that admin.
  5. Create default brands, categories, and products.
  6. Send an email to the admin telling: "Your store is ready."
  7. Send an email to the super admin telling"A new store has been registered".

Now think:

Would you put all this in the controller? In a service? In a job?

No. Just fire an event:

event(new StoreCreated($store));
Enter fullscreen mode Exit fullscreen mode

This will trigger all the actions that are required to complete this process. Here's how we do it.

php artisan make:event StoreCreated
Enter fullscreen mode Exit fullscreen mode

This will generate the following class in app/Events folder.

<?php

namespace App\Events;

use App\Models\Store;
use Illuminate\Foundation\Events\Dispatchable;

class StoreCreated
{
    use Dispatchable;

    /**
     * Create a new event instance.
     */
    public function __construct(
        public Store $store,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Next we need to create listeners for this event.

php artisan make:listener AssignStore --event=StoreCreated
php artisan make:listener AssignAdminRole --event=StoreCreated
php artisan make:listener AssignAdminAbilities --event=StoreCreated
php artisan make:listener AddDefaultBrands --event=StoreCreated
php artisan make:listener AddDefaultCategories --event=StoreCreated
php artisan make:listener AddDefaultProducts --event=StoreCreated
php artisan make:listener SendStoreReadtEmail --event=StoreCreated
php artisan make:listener SendNewStoreCreatedEmail --event=StoreCreated
Enter fullscreen mode Exit fullscreen mode

Register Listeners: The New Way

If we create a listener, Laravel will automatically scan the `Listeners` directory and register it for us. When we define a listener method like `handle` and type-hint the event in its signature, Laravel knows which event it should respond to. For example, if you run:

php artisan make:listener AssignStore --event=StoreCreated
Enter fullscreen mode Exit fullscreen mode

Laravel will generate the listener and link it to the `StoreCreated` event automatically.

Note: You can also create directory inside listeners directory to group all the listeners that are required to be processed togather. For example to keep it simple use the same name as event to create a directory.

app/
└── Listeners/
    └── StoreCreated/
        └── AssignStore.php
        └── AssignAdminRole.php
        └── AssignAdminAbilities.php
        └── AddDefaultBrands.php           
        └── AddDefaultCategories.php
        └── AddDefaultProducts.php
        └── SendStoreReadtEmail.php           
        └── SendNewStoreCreatedEmail.php
Enter fullscreen mode Exit fullscreen mode

And our command will look like this.

php artisan make:listener StoreCreated/AssignStore --event=StoreCreated
Enter fullscreen mode Exit fullscreen mode

My Customization

But if you want to prefer the old way just (as i do) you can do it like this.

php artisan make:provider EventServiceProvider
Enter fullscreen mode Exit fullscreen mode

This will generate EventServiceProvider class in app/Providers folder.

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;

class EventServiceProvider extends ServiceProvider
{
    /**
     * Register services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap services.
     */
    public function boot(): void
    {
        //
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we can create $listen property and do the stuff.

protected $listen = [

    StoreCreated::class => [
        AssignStore::class
        AssignAdminRole::class
        AssignAdminAbilities::class
        AddDefaultBrands::class
        AddDefaultCategories::class
        AddDefaultProducts::class
        SendStoreReadtEmail::class
        SendNewStoreCreatedEmail::class
    ],
]; 
Enter fullscreen mode Exit fullscreen mode

If we do this we are free to place our classes anywhere in the application like services, helpers or AQC. Just we have to define handle() method and type-hint the event class into it.

Finally we can call the event after we have saved the store data. I usually prefer to do it in Observer class.

<?php

namespace App\Observers;

use App\Models\Store;
use App\Events\StoreCreated;

class StoreObserver
{
    public function created(Store $store)
    {  
        event(new StoreCreated($store));     
    }
}
Enter fullscreen mode Exit fullscreen mode

Final Thoughts

When building scalable Laravel applications, it’s easy to fall into the trap of overloading controllers or service classes with too much responsibility. Events and listeners provide a clean and elegant way to separate concerns while keeping your codebase flexible. By splitting processes into small, independent listeners, you gain testability, maintainability, and the ability to extend workflows without fear of breaking existing logic. Whether you stick to Laravel’s automatic event discovery or prefer the old-school `EventServiceProvider` mapping, the key is consistency. Once you start embracing events, you’ll find that your code naturally evolves into a system that’s easier to reason about, easier to test, and far more adaptable to future requirements.


If you found this post helpful, consider supporting my work — it means a lot.

Support my work

Top comments (5)

Collapse
 
xwero profile image
david duymelinck

Events are not always the right solution. In the case of the example you need to have confirmation that the store is saved, that the user is made admin of the store and that the permissions are set. And those actions should be run sequential.
Events don't provide a way to notify you when an event action failed or not. You have to write the outcome somewhere so it can be fetched.
Eventlisteners wil run out of the box in the order added, but that is not guaranteed. You should think of eventlisteners as parallel running processes.

The pattern to use for the actions I mentioned is the Chain of responsibility pattern. In Laravel that pattern is used for the routing middleware.

The simplest implementation is

class CreateNewStoreAction
{
    public function __construct(
        private StoreRepositoryInterface $storeRepository,
       private NewStoreUserAction $newStoreUserAction,
    ) {}

    public function __invoke(Store $store, User $user) : Store|Error
    {
         $savedStore = $this->storeRepository->create($store); // returns Store or null

          if($savedStore) {
             return $this->newStoreUserAction($savedStore, $user);
          }

          return Error('Store was not saved');
    }
}

class NewStoreUserAction
{
      public function __construct(private UserRepositoryInterface $userRepository) {}

      public function __invoke(Store $store, User $user) : Store|Error
     {
            $isAdmin = $this->userRepository->setStoreAdmin($store->id, $user->id); // returns boolean

            if($isAdmin == false) {
                  return Error('Failed to save store admin')
            }

            if($isAdmin && empty($store->extraFeatures)) {
                  return $store;
            }

            $hasExtras = $this->userRepository->saveExtraFeatures($store, $user); // returns boolean

            return $hasExtras ? $store : Error('Failed adding extra features.');
     }
}
// In the controller
$result = $createNewStoreAction($store, $user); // class instantiated by autowiring

if($result instanceof Error) {
// do something
}

event(new NewStoreSaved($store, $user)); // email listeners are triggered
Enter fullscreen mode Exit fullscreen mode

Because one class calls another the chain is not changeable in the controller. So this should be used in case the chain only changes once in a blue moon.
When chain changes more often create a way to make the chain configurable.

Collapse
 
raheelshan profile image
Raheel Shan

I see what you’re saying, but I don’t agree that the Chain of Responsibility pattern is the right fit here. What you’re describing with separate action classes returning error objects is basically reinventing what Laravel already gives us with transactions and exceptions.

In my example, all three steps (store creation, admin assignment, extra permissions) can safely run inside a database transaction. If something fails, the transaction rolls back automatically, and I don’t need to wrap each operation in a custom Error return. That’s simpler, less boilerplate, and leverages the framework instead of building a mini-framework on top of it.

Events weren’t meant to guarantee persistence, they’re for reacting after the business logic has succeeded. For the “must succeed in sequence” part, transactions and exceptions are more natural. Adding a Chain of Responsibility here feels like ceremony without a real payoff.

Collapse
 
xwero profile image
david duymelinck • Edited

all three steps (store creation, admin assignment, extra permissions) can safely run inside a database transaction

That is not how events work. You can't have three listeners; AssignStore, AssignAdminRole, AssignAdminAbilities, and have a single database transaction. I'm basing my code and assumptions on your example.

If something fails, the transaction rolls back automatically, and I don’t need to wrap each operation in a custom Error return

You still need to know which part of the transaction failed, and that means you need to check the generic database transaction error.
And that is a lot more error prone than creating custom errors from the start. My example could be improved by creating specific error classes instead of a generic error class.

building a mini-framework

Calling it a mini-framework is pushing it, this is one extra layer of abstraction. Your AQC pattern does the same thing for different reasons.

For the “must succeed in sequence” part, transactions and exceptions are more natural.

So we agree that the actions/steps should be in sequence. That is the reason you shouldn't use listeners. How are you going to get the store id when AssignStore and AssignAdminRole are not aware of each other?

Adding a Chain of Responsibility here feels like ceremony without a real payoff

The chain of responsibility pattern is a concept to describe one action can be triggered after another action is done, and this can become a chain. The examples are just implementations. You can dispatch an event in a listener to create a chain, and there are probably more implementations.

Thread Thread
 
raheelshan profile image
Raheel Shan

You’re mixing persistence with side effects. The store creation, admin role, and abilities aren’t “events”—they’re domain operations that should live inside a transaction. Laravel’s transactions + exceptions already give you sequential execution, rollback, and a clear failure point without wrapping every step in custom error objects.

Events make sense after the transaction succeeds (emails, logging). Listeners aren’t supposed to depend on each other, so chaining them just to pass IDs around is misuse.

And unit testing is way easier this way: one service/action class, one test, assert rollback on failure. No need to mock a bunch of listeners or juggle custom errors to prove something the database and exceptions handle for free.

Thread Thread
 
xwero profile image
david duymelinck • Edited

The store creation, admin role, and abilities aren’t “events”

Are you saying the three listeners; AssignStore, AssignAdminRole, AssignAdminAbilities from your post don't execute a query?

Can you show where I mentioned those actions are events?

Laravel’s transactions + exceptions already give you sequential execution

The post isn't about transactions, so why do you keep on hammering on the one functionality that has nothing to do with the post. You just want to steer the conversation in another direction.

Events make sense after the transaction succeeds

You never created fail events?

Listeners aren’t supposed to depend on each other, so chaining them just to pass IDs around is misuse.

Why is it misuse? Event-driven programming is build on chaining events.

No need to mock a bunch of listeners

There is no reason to mock listeners, they can be tested on their own. Chaining doesn't change how listeners work.
The only extra thing you need to test is the dispatching of an event. How that event is handled is out of scope.