# The Laravel Bug Your Tests Will Never Catch

**Author:** Mozex | **Published:** 2026-04-15 | **Tags:** Laravel, PHP, Testing, Database | **URL:** https://mozex.dev/blog/16-the-laravel-bug-your-tests-will-never-catch

---


In a [previous post](/blog/12-5-laravel-queue-failures-that-only-show-up-in-production), I listed "your job runs before the data exists" as one of five queue failures that only show up in production. I showed the `afterCommit()` fix and moved on.

But that post didn't answer the question that kept nagging me: why do your tests always pass when this bug is sitting right in your code? And where else in your app is this same timing problem hiding, beyond direct `dispatch()` calls?

This post digs into both.

<!--more-->

## The Bug, Briefly

If you haven't read [the original post](/blog/12-5-laravel-queue-failures-that-only-show-up-in-production), here's the short version. You have an observer that dispatches a job when a model is created:

```php
class UserObserver
{
    public function created(User $user): void
    {
        SendWelcomeEmail::dispatch($user);
    }
}
```

And somewhere in your app, that model gets created inside a transaction:

```php
DB::transaction(function () use ($request) {
    $user = User::create($request->validated());
    $user->profile()->create(['bio' => '']);
    $user->settings()->create(Settings::defaults());
});
```

The `created` event fires the moment `User::create()` runs, not when the transaction commits. Your observer dispatches the job to the queue immediately, while the transaction is still open.

Your queue worker is a separate process with its own database connection. If it picks up that job before the transaction commits, `User::find($user->id)` returns `null`. The row exists on your web process's connection, but it's invisible to every other connection until the commit lands.

Under light load, the timing works out and you never notice. Under real traffic, jobs start failing intermittently. [The full walkthrough is in the original post.](/blog/12-5-laravel-queue-failures-that-only-show-up-in-production)

## Why Your Tests Are Blind to This

This is the part that burns.

Your tests use `RefreshDatabase`, which wraps each test in a transaction. Your queue connection is set to `sync` in your test environment, which means jobs run immediately in the same process.

Same process means same database connection. Same connection means the job can see uncommitted data from the wrapping transaction.

So your test creates a user, the observer fires, the job runs synchronously, `User::find()` works perfectly, and your test goes green. Every single time.

The production queue worker runs in a different process. Different process, different connection, different view of the database. Your test suite can't reproduce this. Not because your tests are bad, but because the `sync` driver and `RefreshDatabase` work together to make the bug invisible.

## It's Not Just Jobs

The welcome email example is annoying but survivable. And if you read the previous post, you might think this only affects direct `dispatch()` calls. It doesn't. The same timing problem hits anything that crosses process boundaries from inside a transaction.

**Payment processing.** You create an order, charge the customer, and dispatch a job to generate the invoice. The job tries to load the order and its line items. Under load, the order doesn't exist yet on the worker's connection. Your customer got charged but never received a receipt.

**Webhook dispatching.** You update an order status inside a transaction and dispatch a job to notify a third-party API. The webhook fires, the third party calls your API to verify the order status, and gets stale data because your transaction hasn't committed.

**Broadcasting.** You fire a broadcast event after creating a record. The frontend receives the WebSocket message and makes an API call to fetch the new data. The API returns 404 because the transaction hasn't committed on the connection serving that API request.

**Audit logging.** You create an audit log entry in a separate job after a sensitive action. The job tries to load the model it's supposed to log and gets null. Your audit trail has gaps that nobody notices until the compliance audit.

Every one of these is a variation of the same problem: something depends on data being visible to other processes before the transaction has committed.

## Every Way to Fix It

The [previous post](/blog/12-5-laravel-queue-failures-that-only-show-up-in-production) covered `afterCommit()` and the `after_commit` config option. But Laravel gives you more than that. Here's the full picture.

### The Global Fix

In `config/queue.php`, add `after_commit` to your queue connection:

```php
'redis' => [
    'driver' => 'redis',
    'connection' => env('REDIS_QUEUE', 'default'),
    'queue' => env('REDIS_QUEUE', 'default'),
    'retry_after' => 90,
    'after_commit' => true,
],
```

With this set, any job dispatched inside a database transaction will wait in memory until the transaction commits before being pushed to the queue. If the transaction rolls back, the job is silently discarded.

This is the safest option and the one I'd pick for most apps. It fixes the problem everywhere with one config change.

### Per-Job Control

If the global fix is too broad (maybe some jobs need to fire before commit), you can control it per-dispatch:

```php
SendWelcomeEmail::dispatch($user)->afterCommit();
```

Or the inverse, if you've enabled global `after_commit` but need a specific job to fire immediately:

```php
ProcessQuickTask::dispatch($data)->beforeCommit();
```

### For Event Listeners

If your listener implements `ShouldQueue`, switch to the `ShouldQueueAfterCommit` interface:

```php
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;

class SendShipmentNotification implements ShouldQueueAfterCommit
{
    // Your listener logic stays the same.
    // It just won't be queued until the transaction commits.
}
```

### For Events Themselves

If the event itself should only dispatch after the transaction commits (meaning ALL listeners wait, not just queued ones):

```php
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;

class OrderShipped implements ShouldDispatchAfterCommit
{
    use Dispatchable, SerializesModels;

    public function __construct(public Order $order) {}
}
```

### For Observers

Observers can opt in by implementing `ShouldHandleEventsAfterCommit`:

```php
use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit;

class UserObserver implements ShouldHandleEventsAfterCommit
{
    public function created(User $user): void
    {
        SendWelcomeEmail::dispatch($user);
    }
}
```

Now the `created` handler won't run until the transaction commits. If there's no active transaction, it runs immediately as before.

## Which Approach to Use

For most applications: set `'after_commit' => true` globally and move on. It's the right default, and I think Laravel should ship it this way out of the box.

Use the per-job `->afterCommit()` call when you're adding the fix to an existing app and want to be surgical about it.

Use `ShouldDispatchAfterCommit` on events when every consumer of that event needs committed data. Use `ShouldQueueAfterCommit` on individual listeners when only some of them need it.

Use `ShouldHandleEventsAfterCommit` on observers when the observer's side effects (jobs, notifications, API calls) all depend on the data being committed.

When you don't need any of this: synchronous work that stays in the current process. If your observer just updates another column on the same model, it's already inside the transaction and will commit with it. The problem only exists when work crosses process boundaries through the queue.

## Check Your App

If you're dispatching jobs, events, or notifications from model observers or event listeners, and any of those run inside a database transaction, you probably have this bug. It might be silent right now. It might only show up under load, or on a slow database night, or when your queue workers are particularly fast. But it's there.

Open your `config/queue.php`. Add `'after_commit' => true`. It costs nothing when there's no active transaction, and it saves you from a class of production bugs that are miserable to track down.