DEV Community

Sharjeel Zubair
Sharjeel Zubair

Posted on

The Laravel Queue + Multi-Tenancy Trap That Cost Me 3 Hours

A postmortem on a bug that passed all my unit tests, all my feature tests, and every manual smoke test — then blew up the first time a real user clicked a button in production.

The setup

I'm building Schoolytics, an open-source multi-tenant helpdesk for schools. Multi-tenancy is row-level: one Postgres database, every tenant-scoped table has a tenant_id column, and every tenant model uses a BelongsToTenant trait that adds a global scope:

trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope('tenant', function (Builder $q) {
            if ($tenantId = tenant('id')) {
                $q->where($q->getModel()->getTable().'.tenant_id', $tenantId);
            }
        });

        static::creating(function ($model) {
            $model->tenant_id ??= tenant('id');
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Simple, effective. Every query a tenant's code makes is automatically scoped. Forget tenant_id? You physically cannot leak another tenant's data.

The feature

When a parent submits an issue through the public portal, we fire an IssueCreated event. A queued listener calls a Python microservice to analyze sentiment, then writes the result to an issue_ai_analysis row. Straightforward event → queued listener → DB write.

class IssueCreated
{
    public function __construct(public Issue $issue) {}
}

class PerformAiAnalysis implements ShouldQueue
{
    use InteractsWithQueue, SerializesModels;

    public function handle(IssueCreated $event): void
    {
        $score = Http::post(config('services.ai.url'), [
            'text' => $event->issue->description,
        ])->json();

        IssueAiAnalysis::updateOrCreate(
            ['issue_id' => $event->issue->id],
            ['sentiment' => $score['label'], 'confidence' => $score['confidence']]
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Green tests. Works in tinker. Ships.

The crash

First real submission in production:

Illuminate\Database\Eloquent\ModelNotFoundException
No query results for model [App\Models\Issue].
Enter fullscreen mode Exit fullscreen mode

But the issue exists. I can SELECT * FROM issues WHERE id = 189 and see it. The listener is being called with an event whose payload clearly references issue 189 — and then Laravel throws ModelNotFoundException trying to rehydrate it.

The trap

Here's the lifecycle of a queued listener:

  1. Controller fires event(new IssueCreated($issue))
  2. Laravel sees the listener is ShouldQueue, serializes the event via SerializesModels
  3. The event is not serialized as the full Issue object — just App\Models\Issue + the primary key (189). This is the whole point of SerializesModels; it keeps payloads tiny and always fresh.
  4. Worker boots, pulls the job, and calls (new Issue)->newQueryForRestoration(189)->firstOrFail() to rebuild the model.

Step 4 is where it dies. The worker process has no tenant context yet — it just started. Which means tenant('id') returns null. Which means BelongsToTenant's global scope generates:

SELECT * FROM issues
WHERE id = 189
  AND issues.tenant_id IS NULL   -- 💀
LIMIT 1
Enter fullscreen mode Exit fullscreen mode

No rows. ModelNotFoundException.

The kicker: this never fails in sync mode (there's no serialization round-trip), and tests typically use Queue::fake() or sync drivers. The bug is invisible until you run a real queue worker against a real tenant request.

Laravel's stancl/tenancy does ship a QueueTenancyBootstrapper that restores tenant context on the worker — but it fires after SerializesModels::restoreModel() runs. Too late. The model is already dead.

The fix

Never put a tenant-scoped Eloquent model directly into a queued event or job. Store scalars, and initialize tenancy yourself inside handle():

class IssueCreated
{
    public function __construct(
        public readonly int    $issueId,
        public readonly string $tenantId,
    ) {}
}

class PerformAiAnalysis implements ShouldQueue
{
    public function handle(IssueCreated $event): void
    {
        // Tenant model itself is NOT tenant-scoped — safe to find()
        $tenant = \App\Models\Tenant::find($event->tenantId);
        tenancy()->initialize($tenant);

        try {
            $issue = Issue::findOrFail($event->issueId);

            $score = Http::post(config('services.ai.url'), [
                'text' => $issue->description,
            ])->json();

            IssueAiAnalysis::updateOrCreate(
                ['issue_id' => $issue->id],
                ['sentiment' => $score['label'], 'confidence' => $score['confidence']]
            );
        } finally {
            tenancy()->end();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And dispatch it with plain values:

event(new IssueCreated(
    issueId:  $issue->id,
    tenantId: tenant('id'),
));
Enter fullscreen mode Exit fullscreen mode

Three rules I now enforce in code review:

  1. Queued events and jobs store scalars only. No Eloquent models in constructor signatures.
  2. Every handle() method that touches tenant data calls tenancy()->initialize() first and tenancy()->end() in finally.
  3. The Tenant model itself must never use BelongsToTenant (otherwise you can't look it up without already having tenant context — chicken-and-egg).

Why SerializesModels is still the right default

The trap is real, but the trait exists for good reasons: tiny payloads, always-fresh data, no stale-attribute bugs. The fix isn't to abandon it — it's to recognize that the trait assumes a single global database context, which breaks the moment you add a tenant dimension.

If you're on single-tenant Laravel, keep passing models. If you're on multi-tenant Laravel with row-level isolation, scalars + manual tenancy()->initialize() are your friend.

How I caught it for good

I added a simple feature test that actually runs the queue:

it('processes queued listeners with correct tenant context', function () {
    $tenant = Tenant::factory()->create();
    tenancy()->initialize($tenant);

    Queue::connection('sync')->... // won't catch it
    // Use actual database queue driver:
    config(['queue.default' => 'database']);

    $issue = Issue::factory()->create();
    event(new IssueCreated(issueId: $issue->id, tenantId: $tenant->id));

    tenancy()->end(); // simulate worker boot with no context
    $this->artisan('queue:work --once --stop-when-empty')->assertExitCode(0);

    expect(IssueAiAnalysis::withoutGlobalScopes()
        ->where('issue_id', $issue->id)->exists())->toBeTrue();
});
Enter fullscreen mode Exit fullscreen mode

Ending tenancy before the worker runs is the critical line — it reproduces what Supervisor does in production.


TL;DR: If you're using stancl/tenancy with row-level isolation and queued events/jobs, never put a tenant-scoped Eloquent model in a queued payload. Pass the ID + tenant ID as scalars, call tenancy()->initialize() in handle(). Your tests won't catch this — only a real queue worker will.

Source code: https://github.com/sharjeelz/eliflammeem-git — the pattern lives in app/Listeners/PerformAiAnalysis.php.

If this saved you 3 hours, drop a ⭐ on the repo. If you've hit it before, I'd love to hear your fix in the comments.

Top comments (2)

Collapse
 
xwero profile image
david duymelinck

If you're on single-tenant Laravel, keep passing models.

Models are big beasts they should not be passed. Even with the trait the event will be forced to take more information than needed to do the job.

Collapse
 
shahzamandev profile image
Sheikh Shahzaman

Queue tenant context is a classic trap. We hit the same issue with row-level tenancy.