Building Web Apps with Laravel Module 17 – Architecture, multi-tenancy and packages
Multi-tenancy and SaaS: one app, many centres
Serve many coaching centres from one Laravel 13 app: pick a tenancy design, find the centre from its subdomain and scope every query to it.
What you will learn
- Choose between one shared database, a schema per tenant and a database per tenant for a SaaS product
- Isolate every Eloquent query of a tenant-owned model to one centre with a global scope that fails closed
- Resolve the current centre from its subdomain and keep generated links on it with URL defaults
- Find tenancy leaks outside the scope, in raw SQL, cache keys, rate limits and queued jobs, and fix each one
- Test isolation with a suite that writes as one centre and reads as another
Before you start
What you’ll learn · Before you start
What you will learn
- Choose between one shared database, a schema per tenant and a database per tenant for a SaaS product
- Isolate every Eloquent query of a tenant-owned model to one centre with a global scope that fails closed
- Resolve the current centre from its subdomain and keep generated links on it with URL defaults
- Find tenancy leaks outside the scope, in raw SQL, cache keys, rate limits and queued jobs, and fix each one
- Test isolation with a suite that writes as one centre and reads as another
Before you start
On this page
Pathshala’s software runs two coaching centres, north and south, from one Laravel app and one database, and forty more want to join. If a north clerk can open one south family’s fee record, south has every reason to leave, so the line between centres must hold in every query, page and job.
multi-tenancy: Serving many customers, the tenants, from one running application and one code base, while keeping each tenant's data and settings apart from every other tenant's. means serving many customers, the tenant: One customer of a multi-tenant application whose data must stay apart from every other customer's, such as one coaching centre in Pathshala. Its id marks the rows it owns., from one running app while keeping their data apart. For a SaaS product of Pathshala’s size the simplest design that holds is one shared database: a centre_id column on every tenant table, a scope that adds the current centre to every Eloquent query of those tables, and middleware that finds the centre from the subdomain. The runs below show each centre seeing only its own rows.
Three ways to keep centres apart, and when each one fits
The designs put the boundary between two centres in different places:
- With single-database tenancy: A multi-tenancy design in which all tenants share one database and one set of tables, kept apart by a tenant id column on each row that every query must filter on., all centres share one set of tables, and a
centre_idcolumn says who owns each row. - With a schema per tenant: A multi-tenancy design that gives each tenant its own copy of the tables in a separate schema of one database, such as a PostgreSQL schema chosen through the connection's search path., each centre has its own copy of the tables inside one PostgreSQL database, picked by the search path.
- With a database per tenant: A multi-tenancy design that gives each tenant a database of its own, which the app connects to for each request and job; migrations and backups then run once per tenant., each centre has a database of its own, which the app connects to for each request and job.
Laravel 13 builds the first design from core features; the other two need connection-switching code of your own or a third-party package, such as stancl/tenancy 3.10.1 for a database per tenant. Answer the four questions, and the table marks what each answer rules out:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Design | Separation of data | Migrations and backups | Reports across centres | Connections to run | Support in Laravel 13 |
|---|---|---|---|---|---|
| One shared database How rows are kept apart: a centre_id column in every tenant table | Mixed every query must filter on centre_id; one missed filter shows another centre's rows | Good one migration run and one backup cover every centre | Good one query, grouped by centre_id | Good one connection pool, however many centres sign up | Good core: a global scope attached with #[ScopedBy] |
| A schema per centre How rows are kept apart: a PostgreSQL schema each, picked by search_path | Mixed tables are apart, but a connection can read any schema it has privileges on | Mixed each migration runs once per schema: 400 centres, 400 runs | Mixed possible in one database, with every schema named in the query | Good one database; the search_path changes per request and job | Poor no core switching: your code sets the search_path |
| A database per centre How rows are kept apart: a database each, picked per request or job | Good a database of its own: one centre's restore touches no other centre | Poor migrations, backups and upgrades run once per centre | Poor a report across centres opens every centre's database in turn | Mixed a connection to each centre's database, and more databases to run | Mixed no core switching; a third-party package automates it |
How the answers decide
- “Must each centre's data live in a database of its own, because a contract or a law says so?” Yes, separate storage is required: rules out One shared database and A schema per centre. Only a database per centre puts each centre's rows in storage of their own; a shared database and a set of schemas both keep every centre in one database.
- “Must each centre's data live in a database of its own, because a contract or a law says so?” No, logical separation is enough: adds a caveat to A database per centre. Without that rule, a database per centre buys separation you pay for in every migration, backup and report.
- “Do you need reports across all centres, such as the fees overdue in every centre?” Yes, the owner reads them: adds a caveat to A database per centre and A schema per centre. A report across centres has to visit every database, or name every schema, so it grows with each centre that signs up.
- “How many centres will one deployment serve?” Hundreds or thousands: adds a caveat to A schema per centre and A database per centre. A migration runs once per schema or database, so hundreds of centres mean hundreds of runs, and a database per centre adds a connection for each.
- “Does your database keep several schemas inside one database, as PostgreSQL does?” No, or I am not sure: rules out A schema per centre. The schema design needs schemas inside one database; without them, choose between the other two.
Most SaaS products of Pathshala’s size fit one shared database. A database per tenant earns its cost only when a contract or a law requires separate storage.
One database, and a scope that adds the centre to every query
Each tenant table, here students, instalments and the clerks in users, gets a centre_id column, and the indexes that serve a centre’s queries start with it. A tenant scope: A global scope that adds the current tenant's id to every Eloquent query of a tenant-owned model, attached with the ScopedBy attribute. The lesson's version refuses to run when no tenant is set. adds the filter so that no controller can forget it: TenantScope is a global scope: A class whose apply() method adds conditions to every Eloquent query of the models it is attached to, for example with #[ScopedBy]. withoutGlobalScope() removes it for one query. More in “Scopes, Eloquent collections and pagination” that reads the current centre from Laravel’s context, and #[ScopedBy(TenantScope::class)] attaches it to Student and Instalment. On those two models the BelongsToCentre trait fills centre_id on new rows from the same context.
tenancy/app/Scopes/TenantScope.php
<?php
namespace App\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Support\Facades\Context;
use RuntimeException;
class TenantScope implements Scope
{
/**
* Adds "where <table>.centre_id = <current centre>" to every Eloquent query of the model.
*/
public function apply(Builder $builder, Model $model): void
{
// Fail closed: with no centre known, refuse the query instead of reading every centre's rows.
$centreId = Context::get('centre_id')
?? throw new RuntimeException('No centre is set for a query on '.$model->getTable());
// Qualified with the table name, so a join with another table that has centre_id stays unambiguous.
$builder->where($model->qualifyColumn('centre_id'), $centreId);
}
} tenancy/app/Models/Student.php
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToCentre;
use App\Scopes\TenantScope;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name'])]
#[ScopedBy(TenantScope::class)]
class Student extends Model
{
use BelongsToCentre;
public function instalments(): HasMany
{
return $this->hasMany(Instalment::class);
}
} tenancy/app/Models/Concerns/BelongsToCentre.php
<?php
namespace App\Models\Concerns;
use App\Models\Centre;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Context;
use RuntimeException;
trait BelongsToCentre
{
/**
* A new row always takes the current centre, so no controller can forget it or set another centre's id.
*/
public static function bootBelongsToCentre(): void
{
static::creating(function (Model $model) {
$model->centre_id = Context::get('centre_id')
?? throw new RuntimeException('No centre is set for a new row in '.$model->getTable());
});
}
public function centre(): BelongsTo
{
return $this->belongsTo(Centre::class);
}
} the migration · tenancy/database/migrations/2026_01_15_000001_create_centres_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* One database for every centre: each tenant-owned table carries centre_id.
*/
public function up(): void
{
Schema::create('centres', function (Blueprint $table) {
$table->id();
$table->string('slug')->unique(); // the subdomain: north.pathshala.test
$table->string('name');
$table->string('plan')->default('starter');
$table->timestamps();
});
// A clerk works at one centre.
Schema::table('users', function (Blueprint $table) {
$table->foreignId('centre_id')->nullable();
});
Schema::create('students', function (Blueprint $table) {
$table->id();
$table->foreignId('centre_id')->constrained();
$table->string('name');
$table->timestamps();
$table->index(['centre_id', 'name']);
});
Schema::create('instalments', function (Blueprint $table) {
$table->id();
$table->foreignId('centre_id')->constrained();
$table->foreignId('student_id')->constrained();
$table->unsignedInteger('amount_paise');
$table->timestamp('paid_at')->nullable();
$table->timestamps();
$table->index(['centre_id', 'paid_at']);
});
}
public function down(): void
{
Schema::dropIfExists('instalments');
Schema::dropIfExists('students');
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('centre_id'));
Schema::dropIfExists('centres');
}
}; tenancy/database/seeders/CentreSeeder.php
<?php
namespace Database\Seeders;
use App\Models\Centre;
use App\Models\Student;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Context;
class CentreSeeder extends Seeder
{
public function run(): void
{
$north = Centre::create(['slug' => 'north', 'name' => 'Pathshala North', 'plan' => 'scale']);
$south = Centre::create(['slug' => 'south', 'name' => 'Pathshala South', 'plan' => 'starter']);
User::factory()->create(['name' => 'Asha', 'email' => 'asha@north.example', 'centre_id' => $north->id]);
User::factory()->create(['name' => 'Ravi', 'email' => 'ravi@south.example', 'centre_id' => $south->id]);
// [name, unpaid instalment in paise or null]: each centre's rows are written inside its own context.
$this->students($north, [['Aarav Shah', null], ['Diya Rao', 450000], ['Kabir Iyer', 300000]]);
$this->students($south, [['Meera Nair', 250000], ['Rohan Das', null]]);
}
private function students(Centre $centre, array $rows): void
{
Context::scope(function () use ($rows) {
foreach ($rows as [$name, $unpaid]) {
$student = Student::create(['name' => $name]);
$student->instalments()->create([
'amount_paise' => $unpaid ?? 450000,
'paid_at' => $unpaid === null ? now() : null,
]);
}
}, ['centre_id' => $centre->id]);
}
} routes/console.php · tenancy/routes/console.php
<?php
use App\Models\Centre;
use App\Models\Student;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Context;
// A command runs outside any request, so no centre is set until the command sets one.
Artisan::command('students:count {centre?}', function (?string $centre = null) {
try {
$count = $centre === null
? Student::count()
: Context::scope(fn () => Student::count(), ['centre_id' => Centre::where('slug', $centre)->value('id')]);
$this->line('students: '.$count);
} catch (RuntimeException $e) {
$this->error($e->getMessage());
return 1;
}
});
// The SQL of one query on the scoped model, as the database receives it.
Artisan::command('students:sql {centre}', function (string $centre) {
$sql = Context::scope(
fn () => Student::orderBy('name')->toRawSql(),
['centre_id' => Centre::where('slug', $centre)->value('id')],
);
$this->line($sql);
}); The seeder writes each centre’s rows inside Context::scope(), which sets the centre for one callback. The session prints one scoped query’s SQL and counts students with and without a centre:
"""Two centres in one database. The seeder writes each centre's rows inside that centre's context, the tenant scope
adds the centre to the SQL of every query on the Student model, and a command that names no centre is refused.
Files: the folder tenancy/ next to this file."""
from session import run
run(
"""
php artisan migrate --force
php artisan db:seed --class=CentreSeeder
php artisan students:sql south
php artisan students:count north
php artisan students:count south
!php artisan students:count
""",
app=True,
overlay=("tenancy",),
) Output
$ php artisan migrate --force INFO Running migrations. 2026_01_15_000001_create_centres_table ................................ DONE $ php artisan db:seed --class=CentreSeeder INFO Seeding database. Database\Seeders\CentreSeeder ...................................... RUNNING Database\Seeders\CentreSeeder ......................................... DONE $ php artisan students:sql south select * from "students" where "students"."centre_id" = 2 order by "name" asc $ php artisan students:count north students: 3 $ php artisan students:count south students: 2 $ php artisan students:count No centre is set for a query on students [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tenant_scope_session.py
South’s query carried "students"."centre_id" = 2, north counted 3 students to south’s 2, and with no centre the scope refused to run. A scope that skipped its filter instead would hand any command that forgot the centre every centre’s rows, without a word.
The scope covers Eloquent queries of the scoped models and nothing else: SQL written by hand, cache keys, file paths, rate-limit counters, broadcast channels and queued jobs need the centre too.
Checkpoint
Check: Three tenancy designs and when each fits · A tenant scope on every tenant model
A request finds its centre from the subdomain
Each centre has its own host, such as north.pathshala.test, and Route::domain('{centre}.pathshala.test') makes one route group: A way to give several routes the same prefix, name prefix, controller, middleware or domain once, instead of repeating it on each route. More in “Routing, route parameters and URL generation” that captures the first label as centre. There the ResolveCentre middleware, the tenant resolver: The code, usually a middleware, that finds the current tenant from the request, by its subdomain, a path prefix or the signed-in user's team, and makes it current for the rest of the request., loads the centre, answers 404 unless the signed-in clerk works there, and keeps the centre’s id as the tenant context: The current tenant's id kept in Laravel's Context for one request or one job. The tenant scope reads it there, and Laravel copies it into log lines and into jobs queued meanwhile. with Context::add(), which Laravel also copies into log lines and queued jobs. It sets URL defaults: Values set with URL::defaults that fill route parameters the caller of route() leaves out, such as the centre in {centre}/timetable. More in “Routing, route parameters and URL generation” so that links stay on the centre’s host, and bootstrap/app.php runs it before rate limiting and route model binding:
routes/web.php · tenancy/routes/web.php
<?php
use App\Http\Middleware\ResolveCentre;
use App\Models\Student;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/', fn () => view('welcome'));
// Every centre's pages: {centre} is the first label of the host, north in north.pathshala.test.
Route::domain('{centre}.pathshala.test')->middleware(ResolveCentre::class)->group(function () {
Route::get('/students', fn () => [
'students' => Student::orderBy('name')->pluck('name'),
'first' => route('students.show', Student::orderBy('id')->first()),
])->name('students.index');
// Route model binding runs the scoped query, so another centre's id is simply not found.
Route::get('/students/{student}', fn (Student $student) => $student->only('id', 'name'))
->name('students.show');
Route::post('/students', fn (Request $request) => Student::create(
$request->validate(['name' => ['required', 'string', 'max:80']])
))->name('students.store');
}); tenancy/app/Http/Middleware/ResolveCentre.php
<?php
namespace App\Http\Middleware;
use App\Models\Centre;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;
class ResolveCentre
{
/**
* Finds the centre named by the subdomain and makes it the current centre for this request.
*/
public function handle(Request $request, Closure $next): Response
{
$centre = Centre::where('slug', $request->route('centre'))->first();
// An unknown centre and a user of another centre get the same answer: not found.
abort_if($centre === null || $request->user()?->centre_id !== $centre->id, 404);
Context::add('centre_id', $centre->id); // read by TenantScope, log lines and queued jobs
URL::defaults(['centre' => $centre->slug]); // route() keeps generated links on this subdomain
$request->route()->forgetParameter('centre'); // route actions do not receive it as an argument
return $next($request);
}
} bootstrap/app.php · tenancy/bootstrap/app.php
<?php
use App\Http\Middleware\ResolveCentre;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Routing\Middleware\ThrottleRequests;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// The centre must be known before rate limiting and route model binding run (both come later).
$middleware->prependToPriorityList(before: ThrottleRequests::class, prepend: ResolveCentre::class);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
})->create(); tenancy/request.php
<?php
/*
* Sends one request through the app, as a browser signed in on that host would, and prints the status and the body.
* php request.php [--as=<email>] [--post] [--sql] <url>
* --as signs that user in first, --post sends a POST from the same origin, --sql prints the SQL the request ran.
* One request per run, like one request per PHP process on a server.
*/
use App\Models\User;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
require __DIR__.'/vendor/autoload.php';
$app = require __DIR__.'/bootstrap/app.php';
$kernel = $app->make(Kernel::class);
$kernel->bootstrap();
$options = getopt('', ['as:', 'post', 'sql'], $rest);
$request = Request::create($argv[$rest], isset($options['post']) ? 'POST' : 'GET', server: [
'HTTP_ACCEPT' => 'application/json',
'HTTP_SEC_FETCH_SITE' => 'same-origin',
]);
$app->instance('request', $request);
if (isset($options['as'])) {
$app['auth']->guard('web')->setUser(User::where('email', $options['as'])->firstOrFail());
}
$queries = [];
if (isset($options['sql'])) {
DB::listen(function (QueryExecuted $query) use (&$queries) {
if (! str_contains($query->sql, '"sessions"') && ! str_contains($query->sql, '"cache')) {
$queries[] = $query->toRawSql();
}
});
}
$response = $kernel->handle($request);
$status = $response->getStatusCode();
$json = json_decode($response->getContent());
$shown = $status < 400 ? json_encode($json, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : ($json->message ?? '');
echo $status.' '.$shown.PHP_EOL;
foreach ($queries as $sql) {
echo 'sql: '.$sql.PHP_EOL;
}
$kernel->terminate($request, $response); Step through Asha’s request for north’s students, and the branch that everyone else takes:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The host names the centre. Asha, a clerk at north, opens north.pathshala.test/students. The first label of the host, north, is the only part of the request that says which centre it is for.
- The domain route captures it. Route::domain('{centre}.pathshala.test') matches the host and keeps north as the centre parameter, the same way a parameter of the path is kept.
- Session and sign-in come first. The web group's middleware run before the centre is checked, so the session, CSRF protection and the signed-in clerk are known by then.
- ResolveCentre picks the centre. ResolveCentre loads the centre whose slug is north and checks that Asha works there. It stores centre_id 1 with Context::add() and sets URL::defaults() to north, then forgets the parameter, so route actions are not handed it.
- Binding waits for the centre. The priority entry in bootstrap/app.php puts ResolveCentre ahead of rate limiting and route model binding, so a student id in a URL is looked up with the centre already known.
- The scope adds the filter. Student::orderBy('name') runs through TenantScope, which adds where students.centre_id = 1. The query log of the run shows that condition on both queries of the students table.
- Links stay on north. route('students.show', ...) fills the centre from the URL defaults, so the link in the answer is north.pathshala.test/students/1. The list holds north's three students and none of south's.
- Everyone else gets 404. Asha on south's host, and anyone on a host that no centre has, stop at ResolveCentre with the same empty 404, so a stranger cannot tell which centres exist.
The session sends five requests with debug mode off; request.php signs a clerk in, and --sql adds the queries:
"""Requests to north.pathshala.test and south.pathshala.test find their centre from the subdomain, and the tenant
scope keeps each request to its own centre's students. A clerk on another centre's host, and any host that no centre
has, gets 404. Files: the folder tenancy/ next to this file."""
from session import run
run(
"""
php artisan migrate --force --quiet
php artisan db:seed --class=CentreSeeder --quiet
# Debug mode off, as on a production server.
export APP_DEBUG=false
php request.php --as=asha@north.example --sql http://north.pathshala.test/students
php request.php --as=ravi@south.example http://south.pathshala.test/students
php request.php --as=ravi@south.example http://south.pathshala.test/students/2
php request.php --as=asha@north.example http://south.pathshala.test/students
php request.php --as=asha@north.example http://east.pathshala.test/students
""",
app=True,
overlay=("tenancy",),
) Output
$ php artisan migrate --force --quiet
$ php artisan db:seed --class=CentreSeeder --quiet
# Debug mode off, as on a production server.
$ export APP_DEBUG=false
$ php request.php --as=asha@north.example --sql http://north.pathshala.test/students
200 {"students":["Aarav Shah","Diya Rao","Kabir Iyer"],"first":"http://north.pathshala.test/students/1"}
sql: select * from "centres" where "slug" = 'north' limit 1
sql: select "name" from "students" where "students"."centre_id" = 1 order by "name" asc
sql: select * from "students" where "students"."centre_id" = 1 order by "id" asc limit 1
$ php request.php --as=ravi@south.example http://south.pathshala.test/students
200 {"students":["Meera Nair","Rohan Das"],"first":"http://south.pathshala.test/students/4"}
$ php request.php --as=ravi@south.example http://south.pathshala.test/students/2
404 No query results for model [App\Models\Student] 2
$ php request.php --as=asha@north.example http://south.pathshala.test/students
404
$ php request.php --as=asha@north.example http://east.pathshala.test/students
404
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tenant_isolation_session.py
North’s list held its three students, and both student queries carried "students"."centre_id" = 1. Ravi of south asked for student 2, a north student, and route model binding ran the scoped query, found nothing and answered 404. Asha on south’s host and anyone on an unknown host got the same 404 from the resolver.
A path prefix, Route::prefix('{centre}'), gives the resolver the same parameter, as in the routing lesson; the starter kits’ teams feature does this for teams, with a membership check and URL defaults.
Checkpoint
Check: Finding the centre from the subdomain
Where tenancy leaks hide: raw SQL, cache keys, rate limits, middleware order and queued jobs, each reproduced and fixed Pro: opens with a Pro, Premium or Ultimate pass
The tenant scope filters the Eloquent queries of two models, and the app touches centre data in many more places. Each run here reproduces one tenancy leak: Any way in which one tenant can read or change another tenant's data, such as a query, a cache key, a file path, a rate-limit counter or a channel that does not carry the tenant's id. in the same two-centre app, then shows the fix.
In this part:
- Raw SQL, a shared cache key and one rate-limit counter, reproduced
- The centre resolved after binding and limits breaks both
- Queued jobs carry the centre only when it was set at dispatch
- Where leaks hide, and the fix for each
1 diagram · 4 runnable examples (AppServiceProvider.php, Python, SendFeeReminder.php, app.without-priority.php, burst.php, jobs.php, routes/console.php, routes/web.php) · about 11 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
A two-centre test suite that catches a leak, and a checklist for every change to tenant data Pro: opens with a Pro, Premium or Ultimate pass
A leak that slipped past once tends to come back with the next report or job someone adds. The suite here acts as two centres in every test, one writing and one reading, so a leak fails a test before it reaches a clerk.
In this part:
- Every test writes as one centre and reads as the other
- A failing data set names the leaked row, and the scoped one shows the fix
- What to check before a change to tenant data ships
- What to keep from both parts
2 runnable examples (PHP, Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise
- Give one more table of your Pathshala, such as attendance, a
centre_id, the scope, the trait and a route in the centre group. - Open
database/database.sqlitein the SQLite viewer and check that each new row has a centre. - Then answer the questions below.
Practice that adapts to you
Practise this lesson
9 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): When does each tenancy design fit: one shared database, a schema per tenant, or a database per tenant?
One shared database with a tenant id on every row fits most products: one migration, one backup and simple reports across tenants, if every query filters on the id. A schema per tenant keeps each tenant’s tables apart inside one PostgreSQL database, with a migration run per schema. A database per tenant fits when a contract or a law requires separate storage, and costs a migration, a backup and connections per tenant.
Three production questions, each with its first step:
-
Mid to senior
Pathshala's month-end report is one raw SQL query that joins instalments to students and keeps rows where students.centre_id is the owner's centre. North's owner finds a south family's instalment in it, though both models carry the tenant scope. How did the row get in, and what do you change in the report, the writes and the schema?
-
Senior to staff
Pathshala runs eight Horizon worker processes. Each reminder names the current centre and links to its payment page, both read from a CurrentCentre class whose static property each job sets in handle() when the job knows its centre. A few south families get reminders in north's name. Explain how, and design what a job must carry and restore.
-
Senior to staff
On the first morning of each month, a large centre on the scale plan queues 30,000 reminders. Every centre's receipts and password-reset mails share the default queue, and small centres wait about 40 minutes for them. Design the queues, workers and limits so that one centre's burst cannot delay another's work, and say what each plan gets.
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does multi-tenancy mean for Pathshala?
Serving many centres, the tenants, from one running app while keeping their data apart, so that a north clerk can never open a south family's records in any query, page or job.
-
What are the three ways to keep tenants apart, and which suits most SaaS products?
One shared database with a centre_id column on every tenant table; a schema per tenant inside one PostgreSQL database; or a database per tenant. Most products of Pathshala's size fit the shared database, which Laravel 13 builds from core features; a database per tenant pays only when a contract or law demands it.
-
How does a tenant scope keep every Eloquent query to the current centre?
TenantScope is a global scope that reads the current centre from Laravel's context, attached with the ScopedBy attribute; a trait fills centre_id on new rows. With no centre set the scope refuses to run, rather than returning every centre's rows.
-
What does a tenant scope not cover?
Anything but Eloquent queries of the scoped models: SQL written by hand, cache keys, file paths, rate-limit counters, broadcast channels and queued jobs all need the centre too.
-
How does a request find its centre from the subdomain?
Route::domain('{centre}.pathshala.test') captures the first label, and the ResolveCentre middleware loads the centre, answers 404 unless the clerk works there, stores its id with Context::add() and sets URL defaults. It runs before rate limiting and route model binding.
Key takeaways
- One shared database with a
centre_idcolumn fits most SaaS products; a database per tenant buys separate storage at a migration and a backup per centre. - Multi-tenancy in one database rests on a global scope, attached with
#[ScopedBy], that adds the current centre to every query of a scoped model and refuses to run without one. - A resolver middleware finds the centre from the subdomain and the clerk, keeps it in the context and sets URL defaults before route model binding runs.
Terms in this lesson
- database per tenant
- A multi-tenancy design that gives each tenant a database of its own, which the app connects to for each request and job; migrations and backups then run once per tenant.
- multi-tenancy
- Serving many customers, the tenants, from one running application and one code base, while keeping each tenant's data and settings apart from every other tenant's.
- schema per tenant
- A multi-tenancy design that gives each tenant its own copy of the tables in a separate schema of one database, such as a PostgreSQL schema chosen through the connection's search path.
- single-database tenancy
- A multi-tenancy design in which all tenants share one database and one set of tables, kept apart by a tenant id column on each row that every query must filter on.
- tenancy leak
- Any way in which one tenant can read or change another tenant's data, such as a query, a cache key, a file path, a rate-limit counter or a channel that does not carry the tenant's id.
- tenant
- One customer of a multi-tenant application whose data must stay apart from every other customer's, such as one coaching centre in Pathshala. Its id marks the rows it owns.
- tenant context
- The current tenant's id kept in Laravel's Context for one request or one job. The tenant scope reads it there, and Laravel copies it into log lines and into jobs queued meanwhile.
- tenant rate limit
- A rate limit counted separately for each tenant, so that one tenant's requests never use up another tenant's allowance.
- tenant resolver
- The code, usually a middleware, that finds the current tenant from the request, by its subdomain, a path prefix or the signed-in user's team, and makes it current for the rest of the request.
- tenant scope
- A global scope that adds the current tenant's id to every Eloquent query of a tenant-owned model, attached with the ScopedBy attribute. The lesson's version refuses to run when no tenant is set.
References
- Laravel 13.x routing, subdomain routing (Laravel (laravel.com))
- Laravel 13.x routing, rate limiting (Laravel (laravel.com))
- Laravel 13.x rate limiting (Laravel (laravel.com))
- Laravel 13.x Eloquent, global scopes (Laravel (laravel.com))
- Laravel 13.x context (Laravel (laravel.com))
- Laravel 13.x URL generation, default values (Laravel (laravel.com))
- Laravel 13.x starter kits, teams (Laravel (laravel.com))
- Laravel 13.x queues (Laravel (laravel.com))
- Illuminate\Database\Eloquent\Attributes\ScopedBy (13.34.0 source) (Laravel (GitHub))
- Illuminate\Database\Eloquent\Model, newQueryForRestoration (13.34.0 source) (Laravel (GitHub))
- Illuminate\Log\Context\ContextServiceProvider (13.34.0 source) (Laravel (GitHub))
- Illuminate\Queue\QueueServiceProvider, the worker's reset (13.34.0 source) (Laravel (GitHub))
- Illuminate\Foundation\Bus\PendingDispatch (13.34.0 source) (Laravel (GitHub))
- Illuminate\Routing\Middleware\ThrottleRequests (13.34.0 source) (Laravel (GitHub))
- PostgreSQL 18, schemas and the schema search path (PostgreSQL Global Development Group)
- Tenancy for Laravel v3, introduction (stancl/tenancy) (Tenancy for Laravel (tenancyforlaravel.com))
- stancl/tenancy on Packagist (Packagist)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress