Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 15 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 5 of 15 minutes free
Free: Three ways to keep centres apart, and when each one fits · One database, and a scope that adds the centre to every query · A request finds its centre from the subdomain. With a pass: 2 in-depth parts and 3 interview answers, about 10 minutes.

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_id column 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:

Interactive · answer and compare Which tenancy design fits your SaaS? Answer the four questions about your product. Each answer rules designs out or adds a caveat, with the reason beside the design it marks.
Must each centre's data live in a database of its own, because a contract or a law says so?
Do you need reports across all centres, such as the fees overdue in every centre?
How many centres will one deployment serve?
Does your database keep several schemas inside one database, as PostgreSQL does?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three tenancy designs against the five things that differ most between them
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.

The scope, a scoped model, the trait, the migration, the seeder and two commands

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:

The scoped SQL, and counts with and without a centre Python · tenant_scope_session.py
"""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

  1. Question 1 of 2 A chain of clinics will sign only if each clinic's records sit in storage of their own, so that one clinic can be restored without touching another. Which design meets that?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A database per tenant

    The table's first question decides this case. When a contract or a law requires separate storage, only a database per tenant fits, and every migration and backup then runs once per clinic.

  2. Question 2 of 2 php artisan students:count ran with no centre in the context. What did TenantScope do, and why is that the safer choice?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It threw an exception, so the command failed instead of counting every centre's students

    The recorded run printed No centre is set for a query on students and exit status 1. Failing closed turns a forgotten centre into an error you see at once, instead of a report with every centre's rows.

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:

The centre's routes, the resolver, its place in the middleware order, and the request script

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:

Interactive · stage by stage One request, from the host to the scoped query Step through Asha's request for north's student list, then the branch that every other clerk and every unknown host takes.
One request, from the host to the scoped query, step 8 of 8. Showing north.pathshala.test, Route::domain(), The web group, ResolveCentre (404, no match), Rate limits and binding, The route's query (centre_id = 1), 200 and three names. In focus: ResolveCentre, refused.404 for othersnorth.pathshala.testGET /studentsRoute::domain()the centre is northThe web groupsession, CSRF, sign-inResolveCentre404, no matchRate limits and bindingrun after the centreThe route's querycentre_id = 1200 and three nameslinks stay on north

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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:

Five requests to north, south and an unknown host Python · tenant_isolation_session.py
"""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

  1. Question 1 of 2 Which of these requests stop in ResolveCentre with 404, before the route itself runs?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • Asha, a north clerk, opens south.pathshala.test/students
    • Anyone opens east.pathshala.test/students, a host that no centre has

    The resolver checks only the host and the clerk. A record of another centre gets past it and is refused one step later, by the scoped query that route model binding runs: the same 404 as for an id that does not exist, so Ravi cannot tell whether north has a student 2.

  2. Question 2 of 2 route('students.show', $student) is called without a centre, and still returns http://north.pathshala.test/students/1. What filled in north?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The URL defaults that ResolveCentre set from the current centre

    URL::defaults() fills route parameters that a caller leaves out, for the rest of the request. That is how the link in the recorded answer stayed on north's host.

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.

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.

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

  1. Give one more table of your Pathshala, such as attendance, a centre_id, the scope, the trait and a route in the centre group.
  2. Open database/database.sqlite in the SQLite viewer and check that each new row has a centre.
  3. 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.

  1. Question 1 of 9 In single-database tenancy, what keeps one centre's rows apart from another's?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: A centre_id column on every tenant row, which every query filters on

    In the shared design the boundary sits in each row. That is why the column, its index and the scope that filters on it carry the whole design.

  2. Question 2 of 9 Pathshala expects 400 small centres, no contract asks for separate storage, and the owner reads reports across all of them. Which design fits?

    Choose one answer.

    Show the answer: Question 2 of 9

    Answer: One shared database with a centre_id column

    With no requirement for separate storage and many small tenants, the shared design wins on every cost the table lists, as long as every query carries the centre.

  3. Question 3 of 9 A centre leaves Pathshala and asks for its data to be removed. In which designs can one statement remove all of that centre's tables or rows?

    Choose every answer that is right.

    Show the answer: Question 3 of 9

    Answer:

    • A database per tenant
    • A schema per tenant

    Where the boundary sits decides how a tenant leaves as well as how it is read. A boundary in each row means a delete in each table; a boundary around tables or a database means one statement.

  4. Question 4 of 9 What does #[ScopedBy(TenantScope::class)] on the Student model do?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: It applies TenantScope to every Eloquent query of Student

    A global scope filters reads of its model. The column comes from the migration, and new rows get their centre from the trait.

  5. Question 5 of 9 A new seeder runs Student::create(['name' => 'Ira Sen']) without Context::scope(). What happens?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: The trait finds no centre and throws, so no row is written

    Writes fail closed for the same reason reads do. The lesson's seeder wraps each centre's rows in Context::scope(), and so must any new one.

  6. Question 6 of 9 One centre now holds 40 % of all instalments, and the small centres' overdue lists have become slow. The only index on the table is on paid_at. Which change follows this lesson's migration?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: An index that starts with centre_id, such as one on centre_id and paid_at

    The migration starts each index with centre_id because the scope puts that column in every query. A centre much larger than the rest makes the reason visible.

  7. Question 7 of 9 What does Route::domain('{centre}.pathshala.test') capture for a request to south.pathshala.test/students?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: south, as the centre parameter

    A domain route parameter works like a path parameter. ResolveCentre reads it, finds the centre and then removes it, so route actions are not handed it.

  8. Question 8 of 9 Ravi, a south clerk, asks south's host for student 2, who belongs to north. Which step answers him with 404?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: Route model binding, whose query the tenant scope limited to south

    Route model binding runs the model's query, so the tenant scope applies to it too. A record of another centre is simply not found.

  9. Question 9 of 9 Pathshala moves to one host, with the centre in the path: pathshala.test/north/students. What has to change?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: The group uses Route::prefix('{centre}') instead of Route::domain(), and the resolver and the scope stay as they are

    Only the place where the centre appears changes. Everything after the resolver reads one value from the context, which is why the design does not care how the centre was found.

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:

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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_id column 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.