Events vs Observers vs Jobs: When to Use What in Laravel

/ Content

Every Laravel developer hits this question sooner or later. You've got some logic that needs to happen after something else happens. Send an email when a user registers, update a cache when a product price changes, generate a report when an order ships. Laravel gives you at least three ways to handle it: events, observers, and jobs. And the docs make them all look equally valid, which doesn't exactly help you choose.

I've gone back and forth on this over the years, made some bad calls, and eventually landed on some strong opinions. Here's where I've ended up.

The Quick Version

Events are for decoupling. Something happened, you broadcast that fact, and one or more listeners react to it. The thing that fires the event doesn't know or care who's listening.

Observers are for model lifecycle hooks. A model was created, updated, deleted, run some code in response. They're tightly coupled to Eloquent's save cycle.

Jobs are for heavy work. Things that take time, might fail, and should run in the background. Sending emails, processing images, hitting external APIs.

These aren't mutually exclusive. An event listener can dispatch a job. An observer can fire an event. They compose together. The question is where to put the entry point.

Events: The Default Choice

If I'm unsure where something belongs, I default to events. They give you the cleanest separation of concerns and the most flexibility down the road.

Here's a concrete example. Say a user places an order. In the controller (or more likely, an action class), you create the order and then fire an event:

class PlaceOrderAction
{
    public function execute(User $user, Cart $cart): Order
    {
        $order = Order::create([
            'user_id' => $user->id,
            'total' => $cart->total(),
            'status' => OrderStatus::Pending,
        ]);

        foreach ($cart->items() as $item) {
            $order->lines()->create($item->toArray());
        }

        event(new OrderPlaced($order));

        return $order;
    }
}

Now you register listeners in your EventServiceProvider or use listener discovery. One listener sends a confirmation email. Another notifies the warehouse. A third updates inventory counts. Each listener is a small, focused class that does one thing:

class SendOrderConfirmation implements ShouldQueue
{
    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->order->user)
            ->send(new OrderConfirmationMail($event->order));
    }
}

The nice thing is that PlaceOrderAction has no idea about emails, warehouses, or inventory. If you add a new requirement, say posting to Slack when an order comes in, you write a new listener and register it. You don't touch the action class at all.

This matters a lot in larger apps. I worked on a project where the order placement flow had seventeen side effects. Seventeen. If those were all inline in a controller, it would've been unreadable. With events, each side effect was its own class, easy to find, easy to test, easy to disable.

Observers: Convenient but Dangerous

Observers feel convenient. You want something to happen every time a model is created? Just drop it in an observer. No explicit event dispatch needed. Eloquent calls your observer automatically.

class OrderObserver
{
    public function created(Order $order): void
    {
        Cache::tags('orders')->flush();

        AuditLog::record('order_created', $order);
    }
}

I used to reach for observers constantly. Then I got burned enough times to change my mind.

The problem with observers is hidden coupling. When you look at the code that creates an order, there's no indication that an observer exists. The model silently triggers code that lives in a completely different file, and if you're not intimately familiar with the codebase, you'll have no idea it's happening.

I've seen this cause real issues. A developer writes a data migration script, creates a bunch of order records, and the observer fires for all of them, sending hundreds of emails and flushing caches on every single insert. They didn't know the observer existed. Why would they? There's nothing at the call site that hints at it.

My rule: I'll use observers for truly universal, low-impact operations. Things like maintaining audit logs or updating timestamps. Anything with side effects that could surprise someone? Use an explicit event. Make the intent visible.

And here's a practical tip: if you do use observers, always make sure the logic works correctly during seeding and testing. Add guards like checking app()->runningInConsole() or use the WithoutEvents trait in tests. You'll thank yourself later.

Jobs: For the Heavy Lifting

Jobs are straightforward. They're for work that shouldn't block the current request. If it takes more than a couple hundred milliseconds, or if it calls an external API that might be slow or down, it belongs in a job.

The decision isn't really "job vs event," it's "should this listener be queued?" In practice, I make most of my event listeners implement ShouldQueue. The event fires synchronously (which is fast, it's just dispatching to a queue), and the actual work happens in the background.

Where jobs make sense on their own, without events, is for explicitly scheduled or user-triggered work. Things like "generate this PDF report" or "import this CSV file." These aren't reactions to something that happened, they're direct commands. For those, I dispatch a job directly:

class ReportsController extends Controller
{
    public function store(GenerateReportRequest $request): RedirectResponse
    {
        GenerateMonthlyReport::dispatch(
            $request->integer('year'),
            $request->integer('month'),
            $request->user(),
        );

        return back()->with('status', 'Report is being generated. We\'ll email you when it\'s ready.');
    }
}

No event needed. The user asked for a thing, you're doing the thing. Direct and simple.

Refactoring a God Controller

Here's what this looks like in practice. I've seen variations of this controller on at least five projects. I call it the "god controller" because it does everything:

// Before: everything in the controller
public function store(Request $request): RedirectResponse
{
    $user = User::create($request->validated());

    Mail::to($user)->send(new WelcomeMail($user));

    $user->profile()->create(['bio' => '']);

    Http::post('https://slack.webhook.url', [
        'text' => "New user: {$user->name}",
    ]);

    Cache::forget('user-count');

    Log::info("User registered: {$user->id}");

    return redirect()->route('dashboard');
}

Six different concerns in one method. Testing this means mocking mail, HTTP, cache, and logging. Adding a new side effect means editing this controller. It's a maintenance trap.

After refactoring: the controller creates the user and fires an event. That's it. Each side effect becomes a queued listener. The welcome email can fail and retry independently. The Slack notification can be disabled in dev. The cache invalidation is testable on its own.

How I Decide

Use events when something happened and other parts of the system might care. This is your default. "Order was placed," "User registered," "Payment failed." Fire the event, let listeners react.

Use observers only for universal model lifecycle concerns that are low-risk and low-surprise. Audit logging, cache maintenance, maybe setting default values. If the side effect would be bad to trigger accidentally during a migration or seed, don't put it in an observer.

Use jobs directly when there's no "event," when the user is explicitly asking for something to happen. Report generation, data imports, bulk operations.

Combine them freely. An event listener that dispatches a job is totally normal. An observer that fires an event is fine too (just be careful about infinite loops with the updating/updated cycle).

The underlying principle: make the intent visible and keep each piece small. When something goes wrong at 2 AM, and it will, you want to be able to trace the flow without holding the entire codebase in your head.