Version 4
Early identification
Section titled “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
Section titled “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
tenantstable, - 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
Section titled “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:
'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
Section titled “Resource syncing rework”Syncable models get deleted when their respective SyncMaster is deleted
Section titled “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:
ResourceSyncing\Events\SyncMasterDeleted::class => [ ResourceSyncing\Listeners\DeleteResourcesInTenants::class,],Soft deletes, force deletes, and restores are now synced (only top-down)
Section titled “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:
class SyncMasterDeleted{ public function __construct( public SyncMaster&Model $centralResource, public bool $forceDelete = false, ) {}}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:
ResourceSyncing\Events\SyncMasterRestored::class => [ ResourceSyncing\Listeners\RestoreResourcesInTenants::class,],Attributes used for creation can now be defined separately from attributes used for updates
Section titled “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
Section titled “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
Section titled “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
Section titled “Schema dump”You can now squash tenant migrations:
$ 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.
/** * 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:
$ php artisan tenants:dump --tenant=7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5 --prune INFO Database schema dumped successfully. INFO Tenant migrations pruned.Microsoft SQL Server support
Section titled “Microsoft SQL Server support”There are now tenant database managers for Microsoft SQL Server: a regular version and a permission-controlled version:
'database' => [ 'managers' => [ 'sqlsrv' => Stancl\Tenancy\Database\TenantDatabaseManagers\MicrosoftSQLDatabaseManager::class, // 'sqlsrv' => Stancl\Tenancy\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager::class, ],],Laravel Scout integration
Section titled “Laravel Scout integration”There’s now a first-party integration bootstrapper for Laravel scout:
'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
Section titled “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
Section titled “Improved tenants:run”The tenants:run command now correctly handles stdin from subcommands.
Listeners for creating and deleting tenant storage
Section titled “Listeners for creating and deleting tenant storage”There are now first-party listeners for managing tenant storage folders upon tenant creation and deletion:
CreateTenantStoragefor creating the tenant’s storage folder upon tenant creationDeleteTenantStoragefor deleting the tenant’s storage folder upon tenant deletion
All you need to do to enable them is uncomment these lines in your TenancyServiceProvider:
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,],Storage::url() support
Section titled “Storage::url() support”This feature adds support for symlinking local tenant disks into public/. It can be used either using events:
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:
$ php artisan tenants:link INFO The links have been created.$ 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:
$ php artisan tenants:link --remove INFO The links have been removed.Tenant-specific maintenance mode improvements
Section titled “Tenant-specific maintenance mode improvements”Version 4 adds dedicated commands for putting tenants into maintenance mode and taking them out of 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.$ php artisan tenants:down --tenants=7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5 INFO Tenant: 7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5. INFO Tenants are now in 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:
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 queryAdded Tenant::current() methods
Section titled “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
Section titled “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.
'database' => [ 'drop_tenant_databases_on_migrate_fresh' => true,]Added cookie support to InitializeTenancyByRequestData
Section titled “Added cookie support to InitializeTenancyByRequestData”The InitializeTenancyByRequestData middleware can now identify tenants using cookies.
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
Section titled “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
Section titled “Added DatabaseSessionBootstrapper”This bootstrapper makes the database session driver compatible with the package.
Impersonation improvements
Section titled “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
rememberparameter set
Added a dedicated feature for tenant-specific mail credentials
Section titled “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
Section titled “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).
INFO Migrating tenant 369a9475-5e21-46f3-a7c9-52bac5f18c79 Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException 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-2ae9ac36acf5Support for defining the tenant connection template using array syntax
Section titled “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:
'database' => [ 'template_tenant_connection' => 'tenant_template',]Now, you can define the template:
- 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' => [],], - 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], - By name (same as in v3):
Connection name reference 'template_tenant_connection' => 'tenant_template'
Added RootUrlBootstrapper
Section titled “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:
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
Section titled “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
Section titled “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
Section titled “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.
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
Section titled “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:
- Host the frontend of the client site on
tenant1.com/tenant1.yourapp.com - Make calls to
/api/foo - The tenant will be identified using
tenant1.com/tenant1.yourapp.com, since the browser automatically adds theOriginheader.
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.