We build Karobar, a school management system for schools in Pakistan. It is a Laravel API with a database per school (tenant), and a React front end.
Then schools started asking for a second campus. This post covers how we added campuses inside a tenant, and five rules we learned the hard way. The biggest lesson: we tested who could read a campus's rows, and never tested where a write could land.
Tenant vs campus: why a campus is not a tenant
The obvious move is "one campus = one tenant". We didn't do that. A school with three campuses is still one school: one curriculum, one fee structure, one owner who wants a single number for "fees collected this month". Separate databases would turn every school-wide question into a cross-database job.
So a campus is a row in a branches table inside the school's own database. Around fifteen "root" tables carry a branch_id: students, staff, classes, vehicles, books, expenses, and so on. Everything else reaches a campus through a parent. An attendance record belongs to a student, a section belongs to a class.
The active campus travels on each request as an X-Branch-Id header, not as a JWT claim. A separate core service mints our tokens, and it knows nothing about a school's interior. Which campuses a user may see lives in a branch_user table, because one person (a regional head, or an accountant keeping two sets of books) can cover more than one.
Reads: one scope, applied through relations
A request-aware BranchScope service narrows queries. For root tables it is a simple whereIn('branch_id', …). For child tables it goes through the relation:
public function applyThrough(Builder $query, string $relation, string $column = 'branch_id', bool $keepNull = false): Builder
{
if (! $this->isNarrowing()) {
return $query;
}
$onCampus = fn (Builder $inner) => $this->applyTo($inner, $column);
// A row that points at nothing belongs to the school, not to nobody.
if ($keepNull) {
return $query->where(fn (Builder $outer) => $outer
->whereDoesntHave($relation)
->orWhereHas($relation, $onCampus));
}
return $query->whereHas($relation, $onCampus);
}
In a QA pass we tried a ?branch_id= query parameter, a forged X-Branch-Id, and fetching another campus's record by id. All three were blocked, the last with a 404 rather than a 403, so the id doesn't leak. Reads were airtight.
Writes: the half we forgot
branch_id was fillable. A trait stamped the campus on new rows only when the payload didn't name one. So a campus admin who sent branch_id in a create body could file a student, or a class, at any campus they liked.
Every campus test we had asked what can this person see. None asked where can this person's write land. The fix sits in the shared CRUD controller and runs before the write. A check after saving would only reject a row that already sits at the wrong campus.
protected function guardBranchPlacement(array $data): void
{
$asked = $data['branch_id'] ?? null;
if (blank($asked)) {
return; // the stamping trait picks the campus
}
$allowed = app(BranchScope::class)->allowedBranchIds(); // null = whole-school user
if ($allowed === null || in_array((string) $asked, $allowed, true)) {
return;
}
throw new ApiException(ErrorCode::FORBIDDEN,
'You can only file records at a campus you are posted to.');
}
Whenever a column decides who may see a row, something must also decide who may set it.
Five rules we keep relearning
1. A campus is a view, not a permission. If the system can't place a user on a campus (no staff row, no posting), do not narrow their queries to nothing. That turns one missing row into a blank app with no error message. Permissions are the policies' job, not the scope's.
2. Don't memoise per-request state in a long-lived object. A route keeps the controller it built and can hand the same objects to the next request. A memo that isn't keyed to the request answers request two with request one's campus. We never register the scope as a singleton or scoped binding.
3. Narrow by the root the screen's numbers come from. We narrowed hostel allocations by the student's campus, while the bed count came from the building. The dashboard proudly showed 108% bed occupancy. A library loan narrows by the book, not the borrower.
4. Check your unique indexes, not just your queries. payroll_runs.period was unique, so the second campus to run September's payroll was refused by the database. It is now unique on (branch_id, period).
5. Never narrow the arithmetic. A clash check for a bus driver, or a count of occupied beds in a room, must read across every campus. A narrowed count would happily double-book a driver or put two children in one bed.
Events carry their own campus
One exception to "a child row reaches its campus through its parent" is events. Attendance, grades, fee invoices and payments record something that happened on a date. When a student transfers campus, last term's registers must stay with the campus that took them. So these four tables stamp the campus at write time. It looks like duplication, but it is two different facts: where the child is now, and where it happened.
The pattern behind all of it
Each bug had the same shape: a boundary enforced on the obvious surface and missed on the aggregate one. Our dashboard stat cards came from a reports module, not from each module's own controller. So when we converted a module, its table narrowed while the numbers above it stayed school-wide. Now, whenever we add a boundary, we grep for every place that counts the thing as well as every place that lists it.
We write practical guides for school owners in Pakistan, and you can try the school system in a live demo with no sign-up. Questions about the multi-tenant setup are welcome in the comments.
Top comments (0)