28 Sep 2026 · Madarik
Keeping every school's data apart in a multi-tenant Laravel app
How Madarik scopes 30 models to the current school with one middleware and one trait, where that stops protecting you, and the test that proves it.
Madarik is a school management platform: students, attendance, grades, fees, the library, messaging. Every school is a tenant, and they all live in one database. Each tenant-owned table carries a school_id column, which keeps one schema and one migration path. The cost is that isolation depends on every query remembering that column. With 30 tenant-owned models, "remember to add the where clause" is not a strategy.
Resolve the tenant once per request
A ResolveSchool middleware runs on both the web and API stacks. It takes the school from the authenticated user and binds it into the container. Super-admins can act on another school through an X-School-Id header, but only if they pass the manage-platform gate:
$schoolId = $user->school_id;
if (($override = $request->header('X-School-Id')) && Gate::allows('manage-platform')) {
$schoolId = (int) $override;
}
app()->instance('school_id', $schoolId);Scope every tenant model with one trait
Tenant-owned models use a BelongsToSchool trait. It does two things: fills in school_id when a model is created, and adds a global scope that limits every query to the current school.
static::creating(function (Model $model): void {
if (empty($model->school_id) && app()->bound('school_id')) {
$model->school_id = resolve('school_id');
}
});
static::addGlobalScope('school', function (Builder $builder): void {
if (app()->bound('school_id') && resolve('school_id')) {
$builder->where($builder->getModel()->getTable().'.school_id', resolve('school_id'));
}
});Qualifying the column with the table name matters. Without it, a query that joins two tenant tables fails with an ambiguous school_id column.
The one model that can't use it
User deliberately does not use the trait. The user is what resolves the tenant, so scoping users by the tenant would be circular: authentication would need the school before it knows who is asking. Queries on users are scoped by hand instead, for example when choosing who receives an announcement:
$base = User::query()->where('school_id', $a->school_id);
return match ($a->audience) {
'all' => $base,
'teachers' => $base->role('teacher'),
'parents' => $base->role('parent'),
// ...
};Those recipients are then notified with chunkById(200), so a large school never loads every user into memory at once. Push notifications are batched 500 tokens per request, the most FCM accepts.
Suspending a whole tenant
A second middleware, CheckSchoolStatus, runs right after the resolver. If the school is suspended, API requests get a 403 with the reason, and browser sessions are logged out and invalidated. Platform staff are exempt so they can still get in and fix things.
Where this stops protecting you
The scope only applies when school_id is bound, and only the middleware binds it. Artisan commands, scheduled tasks and queued jobs run without it, so there every tenant query is unscoped. That is intentional, because platform-wide jobs need to see every school, but it means any code outside a request has to set or filter the school explicitly.
Testing the property, not the code
The suite had over a hundred Pest tests, but every one of them bound school_id by hand, and none created two schools to prove one can't read the other's data. The scope made a leak unlikely; it didn't make it tested. So I added a test that finds every model using BelongsToSchool by reflection, seeds one row per school, and asserts only the current school's row comes back:
it('scopes queries to the current school', function (string $model): void {
$mine = $model::factory()->create(['school_id' => $this->schoolA->id]);
$theirs = $model::factory()->create(['school_id' => $this->schoolB->id]);
app()->instance('school_id', $this->schoolA->id);
$ids = $model::query()->pluck('id');
expect($ids)->toContain($mine->getKey())
->and($ids)->not->toContain($theirs->getKey());
})->with(tenantModelsWithFactories());Because the models are discovered, a new tenant model is covered as soon as it has a factory. A second set of tests goes through HTTP and the real middleware: listing students, fetching another school's student by id, and the X-School-Id header, which must be ignored for regular admins and honoured for super-admins.
To check the tests could actually fail, I disabled the scope and reran them: 22 of 26 failed. The four that still passed were informative too. Fetching another school's student by id stayed blocked, because the policy compares school_id on its own. That second layer is exactly what you want when one of them slips.
Takeaway
Put tenant isolation somewhere nobody has to remember it, a scope rather than a where clause, and write down the places the scope deliberately doesn't reach. Then test the property you actually care about: that tenant B's data is invisible to tenant A.