Amitav Roy

The Weekend Bug That Taught Me About Laravel's Event System


Published on: 30th Nov, 2025 by Amitav Roy
Tests passing locally but failing in CI? Laravel's event auto-discovery uses filesystem ordering, which varies between macOS and Linux. Learn why explicit registration matters for production.

I was working on a side project over the weekend when I hit one of those bugs that makes you question everything. You know the type - tests passing perfectly on your machine, green checkmarks everywhere, code looks clean. You push with confidence, then watch GitHub Actions turn red. Two failing tests.

The logical part of my brain said "environment difference, probably database seeding." The paranoid part whispered "race condition." I re-ran locally. Green. Pushed again. Red.

This went on for longer than I'd like to admit.

The Setup: User Registration and Team Setup

The feature itself was straightforward. When a user registers, two things need to happen:

  1. Create a default team for that user
  2. Set up default variant types associated with that team

Classic event-driven architecture. One event (Registered), two listeners (CreateDefaultTeamForUser and CreateDefaultVariantTypesForUser). I'd built this pattern dozens of times.

The tests verified both behaviors independently and together. Locally, everything worked. On CI? The variant types listener was failing with a foreign key constraint error.

Error: SQLSTATE[23000]: Integrity constraint violation: Cannot add or update a child row: a foreign key constraint fails (variant_types.team_id)

That error made no sense. The team creation listener runs first, creates the team, then the variant types listener uses that team ID. Except... apparently not on Linux.

The Investigation: When Logs Tell Strange Stories

I did what any developer does when stumped - added logging everywhere. Debug statements in both listeners, timestamps on every action, stack traces for good measure.

Local execution (macOS):

[INFO] CreateDefaultTeamForUser executed at 14:23:01.234
[INFO] CreateDefaultVariantTypesForUser executed at 14:23:01.456

CI execution (Ubuntu):

[INFO] CreateDefaultVariantTypesForUser executed at 14:23:01.234
[INFO] CreateDefaultTeamForUser executed at 14:23:01.456

The listeners were running in reverse order. On CI, the variant types were trying to reference a team that didn't exist yet.

My first thought: "That's impossible. Event listeners don't just randomly reorder themselves."

My second thought: "I need to understand how Laravel actually discovers these listeners."

Down the Rabbit Hole: How Auto-Discovery Works

I started digging into Laravel's event discovery mechanism. Turns out, when you don't explicitly register your events in EventServiceProvider, Laravel scans your app/Listeners directory and automatically wires things up based on type hints.

It's a fantastic developer experience feature - write a listener, type-hint the event it handles, and Laravel figures out the rest. No boilerplate registration code. Just works.

Except for one detail buried in the implementation: the discovery process relies on filesystem scanning. And filesystem directory ordering isn't consistent across operating systems.

On macOS, the filesystem returned my listener files alphabetically:

  • CreateDefaultTeamForUser.php (starts with 'CreateD')
  • CreateDefaultVariantTypesForUser.php (starts with 'CreateDe')

On Linux, the ordering was different. Sometimes alphabetical, sometimes by inode, sometimes by creation time. The behavior wasn't deterministic.

Laravel's auto-discovery was doing exactly what it's designed to do - scan the directory and register whatever it finds. The problem: I was depending on execution order, and auto-discovery makes no guarantees about order.

The Pattern I'd Missed

This is the kind of architectural smell you develop a nose for over time. I had two listeners that weren't actually independent - one created data that the other depended on. They had an implicit ordering requirement.

In my mental model, I thought "these both respond to the same event" meant they were parallel, independent operations. But the code told a different story:

class CreateDefaultVariantTypesForUser
{
    public function handle(Registered $event): void
    {
        $team = Team::where('user_id', $event->user->id)->first();
        
        // This assumes the team exists
        // But there's no guarantee CreateDefaultTeamForUser ran first
        foreach (['A', 'B', 'C'] as $type) {
            VariantType::create([
                'team_id' => $team->id,  // Foreign key constraint violation if team doesn't exist
                'name' => "Variant {$type}",
            ]);
        }
    }
}

The dependency was implicit in the code but not explicit in the architecture. And implicit dependencies are exactly where "works on my machine" bugs live.

The Solution: Explicit Registration

Once I understood the problem, the fix was straightforward. Stop relying on auto-discovery and explicitly register the event-to-listener mapping:

// app/Providers/EventServiceProvider.php
protected $listen = [
    Registered::class => [
        CreateDefaultTeamForUser::class,           // Guaranteed first
        CreateDefaultVariantTypesForUser::class,   // Guaranteed second
    ],
];

The $listen array preserves order. Laravel processes listeners in the order you define them. No filesystem scanning, no non-deterministic behavior.

Pushed to GitHub. Watched CI. Green.

What I Should Have Known

Reading through Laravel's documentation more carefully after solving this, I found this line:

"Event discovery is a convenience feature for development. For production applications, you should cache your events and explicitly register critical event-to-listener mappings."

That word "convenience" is doing a lot of work. It's telling you: this is great for rapid development, but don't depend on it for anything where ordering or reliability matters.

The documentation doesn't explicitly warn about cross-platform ordering issues, but it's implied in that guidance about production applications. My mistake was treating convenience as production-ready without understanding the tradeoffs.

The Broader Architectural Lesson

This bug taught me something about event-driven architecture that goes beyond Laravel specifics: if your listeners depend on each other's side effects, your architecture has a problem.

Looking back at my code, I had painted myself into a corner. The variant types listener needed a team ID, and I thought "well, the team listener creates that, so I'll just query for it." That query was hiding an architectural dependency.

Here's the thing: when you find yourself writing code like this, you're fighting the framework. Event listeners are meant to be independent, parallel operations. The moment one listener queries for data that another listener created, you've broken that model.

So what's the right approach? After thinking through this, I realized I had two clean options:

Option 1: Make the dependency explicit by returning the data

class CreateDefaultTeamForUser
{
    public function handle(Registered $event): void
    {
        $team = Team::create([
            'user_id' => $event->user->id,
            'name' => "{$event->user->name}'s Team",
        ]);
        
        // Immediately handle the dependent operation
        $this->createDefaultVariantTypes($team);
    }
    
    private function createDefaultVariantTypes(Team $team): void
    {
        foreach (['A', 'B', 'C'] as $type) {
            VariantType::create([
                'team_id' => $team->id,
                'name' => "Variant {$type}",
            ]);
        }
    }
}

This keeps everything in one place. Yes, it violates Single Responsibility Principle, but it makes the dependency obvious. Anyone reading this code immediately understands: "team creation triggers variant type creation." No hidden coupling, no implicit ordering requirements.

Option 2: Pass the data through the event itself

The cleaner architectural solution is recognizing that if the variant types need the team, maybe they shouldn't be listening to Registered at all:

class CreateDefaultTeamForUser
{
    public function handle(Registered $event): void
    {
        $team = Team::create([
            'user_id' => $event->user->id,
            'name' => "{$event->user->name}'s Team",
        ]);
        
        // Fire a new event with the data that dependent operations need
        event(new TeamCreated($team, $event->user));
    }
}

class CreateDefaultVariantTypesForUser
{
    public function handle(TeamCreated $event): void
    {
        // No queries needed - the event carries the team
        foreach (['A', 'B', 'C'] as $type) {
            VariantType::create([
                'team_id' => $event->team->id,
                'name' => "Variant {$type}",
            ]);
        }
    }
}

Now the dependency is explicit in the event flow: Registered → team created → TeamCreated event → variant types created. Each listener is independent, testable, and the data flow is clear.

The second approach is cleaner, but the first approach is honest. Both are better than what I had: two listeners responding to the same event with a hidden dependency buried in a database query.

The Real Lesson: Listener ordering is a red flag

If you find yourself caring about listener order, step back and ask: "Why do these listeners need to coordinate?" Usually, the answer reveals that you're using events wrong. Events should trigger independent side effects. The moment those side effects need to coordinate, you need either synchronous execution (Option 1) or event chaining (Option 2).

Don't fight the framework by relying on execution order. Make your dependencies explicit in the code.

When Auto-Discovery Works (And When It Doesn't)

After this experience, I developed clearer guidelines for when to use auto-discovery:

Use auto-discovery when:

  • Listeners are completely independent
  • Order of execution doesn't matter
  • You're prototyping or in early development
  • The event triggers fire-and-forget side effects (logging, notifications that can fail independently)

Use explicit registration when:

  • Listeners have any dependency on each other
  • You're shipping to production
  • Order matters (and it usually does, even when you think it doesn't)
  • The behavior needs to be consistent across environments

The convenience of auto-discovery is real - you save boilerplate and move faster during development. But that convenience comes at the cost of determinism.

The Production Checklist

If you're using Laravel's event auto-discovery, here's your migration checklist:

  1. Audit your listeners - which ones depend on side effects from other listeners?
  2. Check your EventServiceProvider - are critical event mappings explicit or auto-discovered?
  3. Cache your events in production - Laravel's documentation is explicit about this: event discovery is convenient for development, but in production you should cache your event manifest. Add php artisan event:cache to your deployment process. This not only speeds up event registration but also locks in the discovery order, making behavior consistent across environments. Use php artisan event:clear when you need to rebuild the cache.
  4. Test in Linux if you develop on macOS/Windows - Docker makes this trivial
  5. Consider event chaining - if listeners must coordinate, they probably shouldn't be listening to the same event
  6. Document dependencies - if you're using explicit registration and order matters, leave a comment explaining why

The event caching step is particularly important because it transforms auto-discovery from a runtime filesystem scan into a deterministic cached manifest. This eliminates the cross-platform ordering issues entirely while also improving performance.

And if you're getting mysterious CI failures while local tests pass, check your event listener order. You might be relying on filesystem ordering you can't control.

What This Cost

Beyond the debugging time, this bug taught me to be more skeptical of framework conveniences. Auto-discovery, auto-loading, magic methods - they're wonderful until you hit their edge cases in production.

The irony: I've been writing Laravel for years, built dozens of event-driven features, and never hit this issue. Why? Because I'd always explicitly registered events out of habit from older Laravel versions. It was only when I embraced the "modern" auto-discovery approach that I encountered this gotcha.


The code for this example is simplified for clarity, but the bug and solution are real. Have you hit this issue? I'd be curious to hear if you discovered it before or after shipping to production.