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');
});
}
}
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']]
);
}
}
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].
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:
- Controller fires
event(new IssueCreated($issue)) - Laravel sees the listener is
ShouldQueue, serializes the event viaSerializesModels - The event is not serialized as the full
Issueobject — justApp\Models\Issue+ the primary key (189). This is the whole point ofSerializesModels; it keeps payloads tiny and always fresh. - 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
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();
}
}
}
And dispatch it with plain values:
event(new IssueCreated(
issueId: $issue->id,
tenantId: tenant('id'),
));
Three rules I now enforce in code review:
- Queued events and jobs store scalars only. No Eloquent models in constructor signatures.
- Every
handle()method that touches tenant data callstenancy()->initialize()first andtenancy()->end()infinally. -
The
Tenantmodel itself must never useBelongsToTenant(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();
});
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)
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.
Queue tenant context is a classic trap. We hit the same issue with row-level tenancy.