Skip to content

Multi-database tenancy

Multi-database teanncy means having a separate database for each tenant. This page will explain how this package implements multi-database tenancy, the available configuration, what customizations you can make, and common gotchas.

When you create a tenant, the TenantCreated event is fired. Then, the listeners defined in TenancyServiceProvider (along with any other listeners) get executed. By default, this is what’s in the TenancyServiceProvider:

app/Providers/TenancyServiceProvider.php
Events\TenantCreated::class => [
JobPipeline::make([
Jobs\CreateDatabase::class,
Jobs\MigrateDatabase::class,
// Jobs\SeedDatabase::class,
// Jobs\CreateStorageSymlinks::class,
// Your own jobs to prepare the tenant.
// Provision API keys, create S3 buckets, anything you want!
])->send(function (Events\TenantCreated $event) {
return $event->tenant;
})->shouldBeQueued(false),
],

Notice that we’re defining a “job pipeline” with all of the jobs we want to be executed when a tenant is created.

A job pipeline is our own abstraction for turning an ordered list of jobs into a listener, that may be optionally queued. What happens here is that, upon tenant creation, we first try to create a database. If that succeeds, we try to also migrate the database. If you uncomment any of the other jobs, they will be executed too — in order. Migrations depend on the database being created, and seeders depend on the database being migrated (having tables).

When tenancy is initialized, the DatabaseTenancyBootstrapper creates a tenant connection and sets it as the default connection for any new queries.

This means that, without any code changes on your part, any logic such as in controllers will simply use the tenant connection — unless instructed not to. You can always use the central connection from the “tenant context”, see the bootstrapper page for more details.

If you ever get an exception with this message: Call to a member function prepare() on null, it means you tried to use a database connection that is no longer valid — it has likely been purged by Tenancy. This could be for instance trying to use the tenant connection after leaving the tenant context. If you read the stack trace, you’ll easily find where exactly this invalid use occurs.

A common cause of that (and similar exception messages) is returning something from the tenant context, which includes a database connection that is later accessed. Namely, if you return model instances from something like $tenant->run() and then pass those model instances somewhere where they’re serialized into arrays/JSON, Laravel will try to reference the connection (to grab some timestamp formatting info) which will throw an exception.

// $admin->connection = an instance of the tenant connection
$admin = $tenant->run(fn () => User::firstWhere('is_admin', true));
// tenant connection no longer exists
return Inertia::render('Foo', [
// Inertia tries to convert the model instance into JSON
'admin' => $admin,
]);

The proper way to do what the code block above is trying to do is to simply serialize the data while still in the tenant context:

$admin = $tenant->run(fn () => User::firstWhere('is_admin', true)->toArray());
return Inertia::render('Foo', [
'admin' => $admin,
]);

Using separate models in central and tenant contexts

Section titled “Using separate models in central and tenant contexts”

If you have similar models in both contexts, it’s a good idea to consider whether you should separate them or not.

Some models do not need to be separated. For instance if you’re using some package in both contexts (usually in conjunction with Universal Routes), it will interact with its models the same way and use the same table (even if in different databases) regardless of the context.

However, if you interact with some model differently in each context, it might be a good fit for separating. A typical example is the User model. Often you might start with a simple user that has the same table definition in both contexts — you just need auth. But as you start adding features, you start to need two separate models, with different relations, different methods, different columns.

The recommendation here is to identify this early (in some cases having a single model with just a few lines of conditional context-aware logic is fine but be aware of likely future developments) and name the models unambiguously — for instance in the SaaS Boilerplate we use Admin for the central user (since all of those users are “internal staff” of the SaaS company, managing tenants) and User for users inside tenants.

That way it becomes hard to write invalid data to the database just by virtue of a wrong User import — an easy mistake to make.