Updated June 2026. Tested on Laravel 13 and PHP 8.4. The ideas hold on Laravel 9 through 12 as well.

A multi tenant application serves many separate customers from one codebase, while keeping each customer's data walled off from the rest. A small business CRM, a school management system, an ERP sold to many companies: all of them are usually multi tenant. The hard part is rarely the feature work. It is making sure tenant A never sees a single row that belongs to tenant B.

People reach for packages and clever magic early, and then spend their evenings debugging that magic. Before any of that, it helps to understand the handful of decisions that actually shape a multi tenant app. This post is about those decisions and the gotchas that come with them. If you have already settled on a database per tenant and just want the wiring, that lives in its own guide: Laravel database per tenant, on the fly connections.

One shared database, or a database per tenant

This is the first fork in the road, and most of the others follow from it.

A single shared database keeps every tenant's rows in the same tables, separated by a tenant_id column. There is no extra infrastructure. The catch is that data isolation now lives in your query layer. Every single query has to be scoped to the current tenant, and the one query you forget to scope is the one that leaks. You also share indexes, so a tenant with a huge dataset slows down the small ones sitting in the same tables.

A database per tenant gives each customer their own database. Isolation is structural rather than something you remember to add to each query. You can export one customer by handing over one database, and a heavy tenant can be moved to its own hardware. The cost is operational: migrations have to run across every database, backups multiply, and background jobs have to know which database to talk to.

There is no universally correct answer. If separate databases are a contractual requirement, the decision is made for you. If you are genuinely free to choose, the shared database with carefully scoped queries is the simpler place to start for most apps, and you can move to separate databases later when the data or the customers grow enough to justify it.

Scoping a shared database properly

If you go with a single database, the whole game is making sure no query escapes its tenant. Doing this by hand on every query is how leaks happen. Lean on Eloquent global scopes instead, so the where tenant_id = ? clause is added for you.

// app/Models/Concerns/BelongsToTenant.php
namespace App\Models\Concerns;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Database\Eloquent\Model;

trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        // Read rows only for the current tenant.
        static::addGlobalScope('tenant', function (Builder $builder) {
            if ($tenant = app('current.tenant')) {
                $builder->where('tenant_id', $tenant->id);
            }
        });

        // Stamp new rows with the current tenant.
        static::creating(function (Model $model) {
            if ($tenant = app('current.tenant')) {
                $model->tenant_id ??= $tenant->id;
            }
        });
    }
}

Pull that trait into every tenant owned model and the scoping stops being something you can forget. The thing to watch is any query that bypasses Eloquent, raw DB:: calls, joins, and reports, because the global scope does not reach those. Those you scope by hand.

It is not only the database

Here is the gotcha that surprises people the first time. Switching the database for the current tenant is not enough, because the database is not the only piece of state your app carries. Anything that holds data on behalf of a tenant has to be isolated too.

  • Cache. If tenant A and tenant B both cache dashboard.stats, they will read each other's numbers. Give each tenant its own cache prefix so the keys never collide.
  • Filesystem. Uploaded files should land in a per tenant path or bucket, not a shared folder.
  • Mail. Set the from address and reply to per tenant, so replies come back to the right customer.
  • Notifications and broadcasting. Per tenant Slack webhooks, per tenant broadcast channels, and so on.

Whenever you change configuration at runtime, two things have to be true: the change has to happen before the component is first used, and any already built instance has to be thrown away. For the database that means config() then DB::purge() and DB::reconnect(). For the cache it means setting a per tenant prefix before the cache store resolves.

// Give the current tenant its own cache namespace.
config(['cache.prefix' => 'tenant_'.$tenant->id]);

// If the cache store was already resolved this request, forget it
// so the new prefix takes effect.
app('cache')->forgetDriver(config('cache.default'));

The same pattern applies to any component: change the config first, then drop the cached instance so it gets rebuilt with the new values.

The session leak nobody expects

This one is worth slowing down for, because it is a real security hole.

Say you run a database per tenant, and a user with id 5 logs into tenant A. Laravel stores that user id in the session. Now imagine tenant B also happens to have a user with id 5. If both tenants share the same session cookie and session store, a user on tenant A can take their session cookie across to tenant B's domain and Laravel will happily authenticate them as tenant B's user 5. The data is isolated, but the session is not, so the login leaks across the wall.

The fix is to make sessions tenant specific, the same way you made the database tenant specific. Isolate the session per tenant, for example by setting a distinct cookie name and store before the session starts.

// In your tenant identification middleware, before the session is used.
config([
    'session.cookie'   => 'tenant_'.$tenant->id.'_session',
    'session.domain'   => $tenant->domain,
]);

A cookie scoped to the tenant's own domain and name cannot be carried to another tenant. Whichever isolation method you pick, do not lean on the user id alone to decide who someone is. Always confirm the authenticated user actually belongs to the tenant being served.

Do you even need a package?

No, you do not. You can build a perfectly solid multi tenant app with nothing more than the tools Laravel already gives you: scope your queries for the current tenant the same way you already scope them for the logged in user, and isolate the cache, files and session as above.

Packages like stancl/tenancy and spatie/laravel-multitenancy are good, and they save you boilerplate once your needs are clear. But they also hide the moving parts, and hidden moving parts are hard to debug when you hit the edge case they did not plan for. The magic covers the common 99 percent beautifully and then leaves you stranded on the last 1 percent if you never learned what it was doing.

So the honest recommendation is this. Understand the mechanics first, even if you adopt a package afterwards. When something breaks at two in the morning, the person who knows how the connection, cache and session are switched will fix it. The person relying purely on magic will be reading source code they have never seen before.

Test the isolation, not just the features

Multi tenancy is one of those areas where a green test suite that only checks features is not enough. Write tests that prove the walls hold. Log in as a tenant A user and assert you cannot read, update or delete a tenant B record. Assert that cached values do not bleed across tenants. Assert that a session minted for one tenant is rejected by another. These tests are what let you sleep at night when you ship a change to the tenant resolution code.

Wrapping up

Multi tenancy comes down to a few decisions, not a magic switch. Pick shared database or database per tenant with eyes open. If you share, scope every query through global scopes and watch the raw queries. Whichever you pick, remember the database is not the only state: isolate the cache, files, mail and especially the session, and change runtime config before the component resolves. Lean on packages if you like, but learn the mechanics first, and cover the isolation with real tests.

If you want the concrete wiring for the database per tenant route, read the on the fly connections guide. Questions about your own setup are welcome in the comments.