> ## Content Index
> Fetch the complete content index at: https://blog.ifirestone.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Multi-Tenant SaaS in Laravel: One Database, a Global Scope, and the Leaks It Doesn't Catch: 02 of 08
- URL: https://blog.ifirestone.dev/multi-tenant-saas-in-laravel-one-database-a-global-scope-and-the-leaks-it-doesnt-catch-02-of-08/
- Published: 2026-11-05T13:00:00.000Z
- Updated: 2026-11-05T13:00:00.000Z
- Description: How to isolate companies inside one Laravel app: pick a tenancy model, resolve the tenant from the URL, enforce it with a scope that fails closed, and test for leaks, including the jobs and commands that run without a user.
- Author: Ivan Firestone
- Tags: multitenant-saas-laravel-en, laravel, architecture, backend, security, #english

Imagine a team that ships an invoicing SaaS in three months. Every query carries a `where('company_id', $companyId)`, reviewers look for it in every pull request, and for half a year nothing goes wrong. Then someone adds a nightly job that emails overdue reminders. The scheduler runs it, so there is no logged-in user, and the helper that used to supply the company id returns null. The query loses its filter without a sound, and the job reads every company's invoices.

Nothing in that story is exotic. It is what happens when tenant isolation depends on every developer remembering one line, and on a request context that jobs and commands don't have.

This article is about isolating companies inside one Laravel app: which tenancy model to pick, how to resolve the current company, how to enforce it so that forgetting becomes impossible, and how to test it. How to organize the business logic itself (Actions, Data objects, Events) belongs to the next articles in the series.

## Three Ways to Give Every Company Its Own Space

The first decision is where each company's data physically lives. There are three common answers.

| Model                               | What keeps tenants apart                                           | What it costs                                                                                                      |
| ----------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| Shared database, company\_id column | Your code, optionally backed by row-level security in the database | Cheapest to run: one migration, one connection pool, one backup. Isolation is only as good as your discipline      |
| Schema per company (PostgreSQL)     | The database schema                                                | A real boundary, but every migration runs once per schema and each request has to switch schema                    |
| Database per company                | The database server                                                | Strongest isolation and per-company restore, but migrations, connections and the bill grow with every new customer |

For a product that is still looking for its market, I would start with the shared database. It echoes the argument in [Modular Monolith or Microservices: A Framework for Deciding Without Dogma](https://dev.to/ifirestone/modular-monolith-or-microservices-a-framework-for-deciding-without-dogma-4ppa?ref=blog.ifirestone.dev): the cheaper option usually gives most of the benefit, as long as you keep the exit open. The price is that isolation now lives in your code. The rest of this article is about making that code hard to bypass.

## The URL Decides Who the Tenant Is

Next question: how does a request say which company it belongs to? A subdomain, a header, the session, a path segment. The tempting option is a "current company" column on the user. Switch once, and every later request just knows. It breaks the moment someone opens two tabs. The column holds one answer per user, the tabs want two, and whichever switched last wins for both.

I prefer the path: `/{company-slug}/invoices`. The URL is the only state that belongs to the request itself. It survives bookmarks and shared links, and it puts the tenant in your logs.

A middleware resolves the slug, checks membership and opens the tenant context for the rest of the request.

```php
class ResolveCompany
{
    public function __construct(private TenantContext $tenant) {}

    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user() ?? throw new AuthenticationException;

        // Bindings have not run yet, so this is still the raw slug.
        $company = Company::where('slug', $request->route('company'))->first();

        abort_if(
            $company === null || ! $user->companies()->whereKey($company->id)->exists(),
            404,
        );

        return $this->tenant->runAs($company->id, fn () => $next($request));
    }
}

```

I return 404 for non-members so the URL doesn't confirm that a company exists. A 403 is friendlier to a legitimate user who mistyped, so pick one on purpose.

There is one trap, and it is about order. Route model binding happens in `SubstituteBindings`, and when `{invoice}` gets bound, that query goes through the model's global scopes. If the tenant isn't set by then, binding runs unscoped. In Laravel's default [middleware priority list](https://laravel.com/docs/13.x/middleware?ref=blog.ifirestone.dev) that middleware also sits before authentication, which is why the code above reads the user itself. Move the tenant middleware ahead of bindings in `bootstrap/app.php`:

```php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->prependToPriorityList(
        before: \Illuminate\Routing\Middleware\SubstituteBindings::class,
        prepend: \App\Http\Middleware\ResolveCompany::class,
    );
})

```

And the routes:

```php
Route::middleware(['auth', ResolveCompany::class])
    ->prefix('{company}')
    ->group(function () {
        Route::resource('invoices', InvoiceController::class);
    });

```

The middleware calls something named `TenantContext`. That is the next piece, and the one the nightly job will need.

## One Place That Knows the Current Company

The fix for the opening story starts with a rule: nothing reads the company id from `auth()->user()`. Everything asks one small object, and anyone who wants to run code as a company has to say so out loud.

```php
final class TenantContext
{
    private ?int $companyId = null;

    public function id(): ?int
    {
        return $this->companyId;
    }

    public function runAs(int $companyId, Closure $callback): mixed
    {
        $previous = $this->companyId;
        $this->companyId = $companyId;

        try {
            return $callback();
        } finally {
            $this->companyId = $previous;
        }
    }
}

```

Register it with `scoped`, not `singleton`:

```php
$this->app->scoped(TenantContext::class);

```

Per the [container docs](https://laravel.com/docs/13.x/container?ref=blog.ifirestone.dev), scoped instances are flushed when an Octane worker takes a new request or a queue worker takes a new job. A plain singleton would carry the previous tenant into the next unit of work, which is the same leak in a different costume. The `finally` block is a second line of defense for the case where code calls `runAs` and throws.

## A Scope That Fails Closed

Now the part everyone writes first. A [global scope](https://laravel.com/docs/13.x/eloquent?ref=blog.ifirestone.dev) filters every query for a model, and a trait can attach it and fill `company_id` on create. The difference from the usual version is what happens when there is no company: it throws instead of skipping the filter.

```php
class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $companyId = app(TenantContext::class)->id()
            ?? throw new MissingTenantContext($model::class);

        $builder->where($model->qualifyColumn('company_id'), $companyId);
    }
}

trait BelongsToCompany
{
    public static function bootBelongsToCompany(): void
    {
        static::addGlobalScope(new TenantScope);

        static::creating(function (Model $model): void {
            $model->company_id ??= app(TenantContext::class)->id()
                ?? throw new MissingTenantContext($model::class);
        });
    }
}

```

Pair it with a migration where the column cannot be null and company comes first in the indexes, since nearly every query filters by it:

```php
$table->foreignId('company_id')->constrained();
$table->string('number');
$table->unique(['company_id', 'number']);

```

The cost of failing closed is real. Admin screens, seeders, reports across all companies and anything else that legitimately needs unscoped data must say so with `Invoice::withoutGlobalScope(TenantScope::class)`. I count that as a feature. Every unscoped query is now a line you can grep for in code review, and the list should stay short.

A scope also has blind spots, and it is worth knowing them before trusting it:

- `DB::table('invoices')` and raw SQL never see Eloquent scopes.
- The `unique` and `exists` validation rules query the table directly, so they ignore the scope. Add the condition yourself: `Rule::unique('invoices', 'number')->where('company_id', $companyId)`.
- Anything that crosses a model that doesn't use the trait is only as safe as that other model.

## The Nightly Job, Again

Back to the reminder job. Under the new rules it can't run as it was written, because nothing sets the company. So the job takes the company explicitly and a job middleware opens the context around it. Job middleware, described in the [queues docs](https://laravel.com/docs/13.x/queues?ref=blog.ifirestone.dev), wraps the job the way route middleware wraps a request.

```php
class RunsAsCompany
{
    public function handle(object $job, Closure $next): void
    {
        app(TenantContext::class)->runAs($job->companyId, fn () => $next($job));
    }
}

class SendOverdueReminders implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $companyId) {}

    public function middleware(): array
    {
        return [new RunsAsCompany];
    }

    public function handle(): void
    {
        // Invoice::query() is now filtered to $this->companyId
    }
}

```

The scheduler fans out one job per company:

```php
Schedule::call(function () {
    Company::query()->each(
        fn (Company $company) => SendOverdueReminders::dispatch($company->id)
    );
})->dailyAt('02:00');

```

There is a cost: N jobs instead of one. In return, a slow or failing company can't block the others, retries stay inside one tenant, and the tenant is visible in the payload instead of hidden in ambient state. `Company` itself doesn't use the trait, because it is the tenant.

## Companies, Users and Roles

In a multi-company product, one person often belongs to several companies. That settles where the role lives. It belongs to the membership, a `company_user` pivot with a `role` column, and not to the user. The same person can be an admin in one company and a viewer in another, and the middleware already knows which company the request is about.

The temptation is to give roles a rank and compare numbers. It works for a while. Permissions as plain strings per role (`invoice:create`, `invoice:void`) age better, because the day someone asks for a role that can void invoices but not edit them, you add a row instead of reshuffling a hierarchy. Check them with Laravel's policies, and pass the membership role, not a user-level flag.

## Prove It With a Test That Tries to Leak

A scope you haven't attacked is a scope you hope works. Three tests cover the story so far, written with Pest:

```php
it('never returns another company\'s invoices', function () {
    $acme = Company::factory()->create();
    $globex = Company::factory()->create();
    $tenant = app(TenantContext::class);

    $tenant->runAs($globex->id, fn () => Invoice::factory()->create());

    expect($tenant->runAs($acme->id, fn () => Invoice::count()))->toBe(0);
});

it('refuses to query without a tenant', function () {
    Invoice::count();
})->throws(MissingTenantContext::class);

it('returns 404 for a company the user does not belong to', function () {
    $other = Company::factory()->create();

    $this->actingAs(User::factory()->create())
        ->get("/{$other->slug}/invoices")
        ->assertNotFound();
});

```

The second test is the one that would have caught the opening story. Once these exist, add a guard that lists every table with a `company_id` column and fails if its model doesn't use the trait. New tables are where leaks are born.

## Where Shared Schema Stops Working

This design has limits, and it is better to see them early. A single large tenant can dominate the indexes and the connection pool, so noisy neighbors are a real possibility. Restoring one company from a backup means extracting its rows from a backup of everyone. And some customers, especially enterprise or regulated ones, will ask for their own database no matter how good your scope is.

The design also leaves you an exit. Because every query goes through one context and one trait, moving a big customer to its own database later is a much smaller refactor than it would be in a codebase full of hand-written `where` clauses. That is the argument for doing it this way even if you never need the exit.

## Conclusion

The nightly job that emailed the wrong customers did not need a cleverer filter. It needed a system where running without a company is an error, and where the error shows up in a test long before a customer sees it. Resolve the tenant from the URL, keep it in one place, make the scope fail closed, and try to leak on purpose.

If you want an action for today, grep your codebase for `auth()->user()->company_id` and `withoutGlobalScopes`, and write the "refuses to query without a tenant" test. In the next article we leave isolation behind and look at where the business logic should live so that controllers stay thin.