Skip to content

Version 4

Early identification

Early identification solves an issue where controller constructors may run before the tenancy middleware, resulting in incorrect dependencies getting injected.

The main idea behind early identification is using the kernel middleware stack instead of the route middleware stack. However, this will apply to all of your routes, making it difficult to use multiple identification middleware, central routes, and universal routes.

Version 4 solves this by introducing a very detailed implementation of early identification. It works like this:

  • You add the tenant identification middleware to the kernel middleware stack:
    bootstrap/app.php
    ->withMiddleware(function (Middleware $middleware) {
    $middleware->append(InitializeTenancyByDomain::class);
    })
  • You choose whether you want routes to be central, tenant, or universal by default:
    config/tenancy.php
    'default_route_mode' => RouteMode::TENANT,
  • Now when you make a request to any route, tenancy will be initialized.
  • To disable tenancy for certain routes, you can use the 'central' middleware.
  • Alternatively, you can set the default route mode to central and then you’d be using the 'tenant' middleware to mark routes as tenant-aware.

Early identification is typically not needed when developing a multi-tenant application from scratch, but it becomes very useful when adding multi-tenancy to an existing codebase or especially integrating with third-party packages.

PostgreSQL RLS

Our implementation of PostgreSLQ RLS is a new take on single-database tenancy:

  • all of your data is in a single database,
  • the usage inside your Laravel app is closer to multi-db than single-db.

Essentially, each tenant gets a new DB user with some PostgreSQL policies created for scoping all SQL queries and statements such that the user can only modify their own data.

Our implementation is highly configurable and extensible, but the main setup is our TableRLSManager:

  • it scans your database schema and looks for relationships to the tenants table,
  • it will make sure any tables that are even indirectly related to a tenant can only be accessed by that tenant or in the central context. This ensures other tenants can’t access the tenant’s data.

This means that you need essentially zero code changes — just like when using multi-db — but you get to have all your data in one database.

Traditionally, the benefits of single-db were:

  • easier devops (only one DB to maintain, faster deployments, …),
  • easier queries spanning multiple tenants.

While the benefits of multi-db were:

  • easier implementation (very few code changes needed),
  • more robust data separation.

PostgreSQL RLS comes with essentialy the best of both worlds. Keep in mind that it’s still an experimental feature, however.

Path identification improvements

v4 improves path identification mainly by introducing route cloning. Route cloning is a feature that lets you clone specified routes to create their tenant counterpart. This is helpful when integrating with third-party packages, where you have no control over how the routes are registered, and you can at most define some middleware in the package’s config.

Route cloning lets you have both the central and tenant versions of the route available. For instance:

// based on this route:
Route::get('/foo', FooController::class)->name('foo');
// tenancy will create this route:
Route::get('/{tenant}/foo', FooController::class)
->middleware(InitializeTenancyByPath::class)
->name('tenant.foo');

Additionally, you can now choose to use a different column of the Tenant model in your route parameters:

config/tenancy.php
'resolvers' => [
Resolvers\DomainTenantResolver::class => [
'cache' => false,
'cache_ttl' => 3600, // seconds
'cache_store' => null, // null = default
],
Resolvers\PathTenantResolver::class => [
'tenant_parameter_name' => 'tenant',
'tenant_model_column' => null, // null = tenant key
'allowed_extra_model_columns' => [], // used with binding route fields
'cache' => false,
'cache_ttl' => 3600, // seconds
'cache_store' => null, // null = default
],
Resolvers\RequestDataTenantResolver::class => [
'cache' => false,
'cache_ttl' => 3600, // seconds
'cache_store' => null, // null = default
],
],

Setting tenant_model_column to e.g. slug will use $tenant->slug for {tenant} route parameters, instead of the tenant key.

You can also use this syntax:

Route::get('/{tenant:slug}/foo', ...);

However, you need to whitelist all columns you use in this way by adding them to allowed_extra_model_columns (see above).

Resource syncing rework

Syncable models get deleted when their respective SyncMaster is deleted

All Syncable (tenant) models now get deleted when their respective SyncMaster (central) model is deleted:

  • Directorycentral_db
    • Directoryusers
      • 1 global_id: foo
      • 2 global_id: bar
      • 3 global_id: baz
  • Directorytenant1_db
    • Directoryusers
      • 1 global_id: bar
      • 2 global_id: foo
      • 3 global_id: baz
  • Directorytenant2_db
    • Directoryusers
      • 1 global_id: foo
      • 2 global_id: baz

In this example, deleting central users 2 and 3 would delete users 1 and 3 in tenant1, and user 2 in tenant2.

To disable this logic, remove the following listener from your TenancyServiceProvider:

app/Providers/TenancyServiceProvider.php
ResourceSyncing\Events\SyncMasterDeleted::class => [
ResourceSyncing\Listeners\DeleteResourcesInTenants::class,
],

Soft deletes, force deletes, and restores are now synced (only top-down)

Continuing the above, when soft deletes are used, SyncMasterDeleted is fired and DeleteResourcesInTenants is executed — same as with normal deletes — except that delete(), which is called in DeleteResourcesInTenants, only soft deletes the models.

When a SyncMaster is force deleted, the same SyncMasterDeleted event is fired, but with the forceDelete property set to true, which leads to the tenant counterpart models getting force deleted:

SyncMasterDeleted.php
class SyncMasterDeleted
{
public function __construct(
public SyncMaster&Model $centralResource,
public bool $forceDelete = false,
) {}
}
DeleteResourcesInTenants.php (via a trait)
if ($force) {
$tenantResource?->forceDelete();
} else {
$tenantResource?->delete();
}

This covers deletes and force deletes when soft deletes are used. The only remaining action is restores, for which we have a separate event and listener:

src/TenancyServiceProvider.php
ResourceSyncing\Events\SyncMasterRestored::class => [
ResourceSyncing\Listeners\RestoreResourcesInTenants::class,
],

Attributes used for creation can now be defined separately from attributes used for updates

This solves an issue where either the tenant or central context may have a significantly more complex version of the synced model, resulting in missing fields when a resource is created as a result of syncing (e.g. being attached to a tenant).

// These values are *merged* into the regular synced attributes list.
public function getCreationAttributes(): array
{
return [
'extra_column', // this column will only be synced when creating, not when updating
'foo' => 'default_value',
];
}

Lazier syncing

Syncing events now only fire when there are changes to the synced columns. Changes to other columns will not trigger syncing.

Polymorphic relations

In version 4, the mappings between synced resources and tenants can be polymorphic. This means that if you have multiple tables you sync between the central and tenant databases, you don’t need to create a new table for each mapping.

todo

Schema dump

You can now squash tenant migrations:

Terminal window
$ php artisan tenants:dump
What tenant do you want to dump the schema for?:
> 7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5
INFO Database schema dumped successfully.

This produces a database/schema/tenant-schema.dump file, which is by default passed to the tenants:migrate command as --schema-path. In other words, the tenants:migrate command looks for this file out of the box, with no extra configuration needed on your end.

config/tenancy.php
/**
* Parameters used by the tenants:migrate command.
*/
'migration_parameters' => [
'--force' => true, // This needs to be true to run migrations in production.
'--path' => [database_path('migrations/tenant')],
'--schema-path' => database_path('schema/tenant-schema.dump'),
'--realpath' => true,
],

You can also use the --prune argument to delete all of your tenant migrations, while keeping your central migrations untouched. Keep in mind that you should only use this if all of your tenants are fully migrated:

Terminal window
$ php artisan tenants:dump --tenant=7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5 --prune
INFO Database schema dumped successfully.
INFO Tenant migrations pruned.

Microsoft SQL Server support

There are now tenant database managers for Microsoft SQL Server: a regular version and a permission-controlled version:

config/tenancy.php
'database' => [
'managers' => [
'sqlsrv' => Stancl\Tenancy\Database\TenantDatabaseManagers\MicrosoftSQLDatabaseManager::class,
// 'sqlsrv' => Stancl\Tenancy\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager::class,
],
],

Laravel Scout integration

There’s now a first-party integration bootstrapper for Laravel scout:

config/tenancy.php
'bootstrappers' => [
Bootstrappers\Integrations\ScoutTenancyBootstrapper::class,
],

It automatically sets the scout.prefix config to the tenant key of the current tenant, with no configuration needed.

Improved URL generation

The InitializeTenancyByPath middleware now automatically sets URL::defaults() for the 'tenant' parameter, making route generation easy — you don’t need to specify the 'tenant' when generating a route to a tenant route:

route('tenant.foo', ['tenant' => tenant('id'), 'foo' => 'bar']);

v4 also comes with the UrlGeneratorBootstrapper which replaces Laravel’s URL generator to produce tenant-aware URLs when you’re in the tenant context.

This is especially used in combination with route cloning, where you may have a route called foo that’s central and a clone called tenant.foo that’s tenant-aware. Based on what context you’re in, route('foo') can produce a link to either foo or tenant.foo.

Improved tenants:run

The tenants:run command now correctly handles stdin from subcommands.

Listeners for creating and deleting tenant storage

There are now first-party listeners for managing tenant storage folders upon tenant creation and deletion:

  • CreateTenantStorage for creating the tenant’s storage folder upon tenant creation
  • DeleteTenantStorage for deleting the tenant’s storage folder upon tenant deletion

All you need to do to enable them is uncomment these lines in your 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), // `false` by default, but you likely want to make this `true` in production.
// Listeners\CreateTenantStorage::class,
],
Events\DeletingTenant::class => [
JobPipeline::make([
Jobs\DeleteDomains::class,
])->send(function (Events\DeletingTenant $event) {
return $event->tenant;
})->shouldBeQueued(false),
// Listeners\DeleteTenantStorage::class,
],

Added JobBatchBootstrapper

This bootstrapper adds support for running queued tenant jobs in batches by properly resetting the database connection used by the DatabaseBatchRepository. It is enabled by default:

config/tenancy.php
'bootstrappers' => [
Bootstrappers\JobBatchBootstrapper::class,
],

Storage::url() support

This feature adds support for symlinking local tenant disks into public/. It can be used either using events:

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), // `false` by default, but you likely want to make this `true` in production.
// Listeners\CreateTenantStorage::class,
],
Events\DeletingTenant::class => [
JobPipeline::make([
Jobs\DeleteDomains::class,
])->send(function (Events\DeletingTenant $event) {
return $event->tenant;
})->shouldBeQueued(false),
// Listeners\DeleteTenantStorage::class,
],

or by calling the tenants:link command:

Terminal window
$ php artisan tenants:link
INFO The links have been created.
Terminal window
$ readlink public/public-7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5
/Users/samuel/Sites/example/storage/tenant7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5/app/public/

Use --force if some tenants already have links.

To remove these links, use tenants:link --remove:

Terminal window
$ php artisan tenants:link --remove
INFO The links have been removed.

Tenant-specific maintenance mode improvements

Version 4 adds dedicated commands for putting tenants into maintenance mode and taking them out of maintenance mode:

Putting all tenants into maintenance mode
$ php artisan tenants:down
INFO Tenant: 7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5.
INFO Tenant: 94ce64af-7015-4c91-b26a-3a8d9d925c20.
INFO Tenants are now in maintenance mode.
Putting a specific tenant into maintenance mode
$ php artisan tenants:down --tenants=7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5
INFO Tenant: 7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5.
INFO Tenants are now in maintenance mode.
Taking tenants out of maintenance mode
$ php artisan tenants:up
INFO Tenant: 7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5.
INFO Tenant: 94ce64af-7015-4c91-b26a-3a8d9d925c20.
INFO Tenants are now out of maintenance mode.

The command accepts the same arguments as php artisan down, with the exception of --render:

php artisan tenants:down --help
Options:
--redirect[=REDIRECT] The path that users should be redirected to
--retry[=RETRY] The number of seconds after which the request may be retried
--refresh[=REFRESH] The number of seconds after which the browser may refresh
--secret[=SECRET] The secret phrase that may be used to bypass maintenance mode
--status[=STATUS] The status code that should be used when returning the maintenance mode response [default: "503"]
--tenants[=TENANTS] The tenants to run this command for. Leave empty for all tenants (multiple values allowed)
--with-pending Include pending tenants in query

Added Tenant::current() methods

Two new methods have been added to the base Tenant model:

public static function current(): static|null
{
return tenant();
}
/** @throws TenancyNotInitializedException */
public static function currentOrFail(): static
{
return static::current() ?? throw new TenancyNotInitializedException;
}

Dropping tenant databases on migrate:fresh

Set the tenancy.database.drop_tenant_databases_on_migrate_fresh config key to true to drop tenant databases on php artisan migrate:fresh.

config/tenancy.php
'database' => [
'drop_tenant_databases_on_migrate_fresh' => true,
]

The InitializeTenancyByRequestData middleware can now identify tenants using cookies.

InitializeTenancyByRequestData.php
class InitializeTenancyByRequestData extends IdentificationMiddleware implements UsableWithUniversalRoutes
{
use UsableWithEarlyIdentification;
public static string $header = 'X-Tenant';
public static string $cookie = 'X-Tenant';
public static string $queryParameter = 'tenant';
public static ?Closure $onFail = null;

Added pending tenants

Pending tenants is a feature that lets you maintain a pool of ready-to-use, pre-made tenants for any tenant that might sign up.

This can simplify your tenant onboarding logic, by not requiring a queued onboarding flow like we use in the v3 SaaS boilerplate. Instead, everything can happen completely synchronously since pending tenants have pre-created databases.

Added DatabaseSessionBootstrapper

This bootstrapper makes the database session driver compatible with the package.

Impersonation improvements

v4 makes three improvements to the impersonation logic:

  • There’s now session state indicating whether impersonating is taking place. This can be used for adding something like a banner to the UI to make it clear that the user is logged in via impersonation.
  • The package now enforces that stateful guards must be used with impersonation tokens.
  • Impersonation sessions can now have the remember parameter set

Added a dedicated feature for tenant-specific mail credentials

v4 adds MailConfigBootstrapper which maps tenant properties to mail config and resets mail-related singletons in the service container, letting you easily have reliable tenant-specific mail configuration.

It’s configured similarly to the TenantConfig feature.

Added skip-failing option to tenants:migrate

You can now use --skip-failing when running php artisan tenants:migrate to ignore tenants that haven’t been fully created yet (i.e. their database doesn’t exist).

php artisan tenants:migrate
INFO Migrating tenant 369a9475-5e21-46f3-a7c9-52bac5f18c79
Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException
php artisan tenants:migrate --skip-failing
INFO Migrating tenant 369a9475-5e21-46f3-a7c9-52bac5f18c79
INFO Migration failed for tenant 369a9475-5e21-46f3-a7c9-52bac5f18c79: Database tenant369a9475-5e21-46f3-a7c9-52bac5f18c79 does not exist.
INFO Migrating tenant 7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5

Support for defining the tenant connection template using array syntax

Previously, you had to create a connection in config/database.php and reference it in your Tenancy config:

config/tenancy.php
'database' => [
'template_tenant_connection' => 'tenant_template',
]

Now, you can define the template:

  1. Fully (same contents as what you’d put into a connection config):
    Full definition
    'template_tenant_connection' => [
    'driver' => 'mysql',
    'url' => null,
    'host' => 'mysql2',
    'port' => '3306',
    'database' => 'main',
    'username' => 'root',
    'password' => 'password',
    'unix_socket' => '',
    'charset' => 'utf8mb4',
    'collation' => 'utf8mb4_unicode_ci',
    'prefix' => '',
    'prefix_indexes' => true,
    'strict' => true,
    'engine' => null,
    'options' => [],
    ],
  2. Partially (the rest will be taken from the central connection):
    Partial definition
    'template_tenant_connection' => [
    'host' => '1.2.3.4',
    'username' => 'root',
    'password' => 'password',
    // the rest of the connection is copied over from the central connection
    ],
  3. By name (same as in v3):
    Connection name reference
    'template_tenant_connection' => 'tenant_template'

Added RootUrlBootstrapper

This bootstrapper lets you change the app.url in CLI context. This is useful e.g. for sending email. When routes are being generated in web context, they’re based on the request hostname. When they’re being generated in a CLI process (like a queue worker), there’s no request hostname, so Laravel defaults to the configured app.url. This results in URLs generated in emails pointing to the central domain, even when tenancy is initialized.

To solve this, you can use the RootUrlBootstrapper:

TenancyServiceProvider.php
RootUrlBootstrapper::$rootUrlOverride = function (Tenant $tenant) {
return 'https://' . $tenant->domains->first()->domain . '/';
};

See the overrideUrlInTenantContext method in your TenancyServiceProvider for a more generic implementation.

Tenant-specific broadcasting

Version 4 adds two approaches for tenant-specific broadcasting:

  • Tenant-specific broadcasting keys
  • Prefixed channel names

For details, see the Broadcasting page of the documentation.

Added cache prefixing bootstrapper

This is a more generic implementation of our CacheTenancyBootstrapper from v3 (which has now been renamed to CacheTagsBootstrapper). The v3 bootstrapper only worked with cache drivers that supported caching, like Redis, and worked by applying cache tags.

The bootstrapper in v4 works by prefixing the configured cache stores. Prefixing is a better approach since it supports more drivers and results in a cleaner structure inside e.g. Redis, making it easy to view the entire cache of any given tenant.

Added single-domain tenants

In many applications, tenants will only have a single domain, making the default HasMany relation excessive. For this, we’ve introduced a new interface: SingleDomainTenant. Tenant models implementing this interface are expected to have a custom domain column storing the tenant’s domain.

app/Models/Tenant.php
class Tenant extends BaseTenant implements TenantWithDatabase, SingleDomainTenant
{
use HasDatabase, HasDomains;
public static function getCustomColumns(): array
{
return array_merge(parent::getCustomColumns(), [
'domain',
]);
}
}

Added origin header identification

Version 4 adds a new identification middleware: InitializeTenancyByOriginHeader. This can be an especially useful approach for SPAs, since you can specify the tenant by controlling what domain you make the request from, rather than having to pass the tenant to each request manually.

And since you can make requests to relative paths, you often won’t need to specify the tenant at all.

This feature leverages multiple aspects of how browsers work, letting you set up SPA client sites like this:

  1. Host the frontend of the client site on tenant1.com / tenant1.yourapp.com
  2. Make calls to /api/foo
  3. The tenant will be identified using tenant1.com / tenant1.yourapp.com, since the browser automatically adds the Origin header.

On the database level, your tenants will be structured as if you were using domain identification. On the middleware level, it will work similar to request data identification (it will read the Origin header but match it against the tenant’s domain instead of the tenant key), and on the frontend, you won’t have to specify the tenant manually at all.