Skip to content

Version 4

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.

Our implementation of PostgreSQL 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.

v4 introduces parallel migrations (as well as seeding and migrate:fresh). Parallel migrations typically offer a speedup of a factor corresponding to the number of CPU cores your server has. In other words, a server with 16 CPU cores could see a 16x speedup. Slow deployments, due to slow migrations, have been a common complaint in v3. Migrations that would previously take 5 minutes can now take around 20 seconds.

To use parallel migrations, simply use the -p option:

Terminal window
php artisan tenants:migrate -p # automatically detects CPU cores
php artisan tenants:migrate -p 16 # use 16 processes
# Available on other commands too:
php artisan tenants:seed -p
php artisan tenants:migrate-fresh -p

For more information see the parallel commands section.

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\PathTenantResolver::class => [
'tenant_parameter_name' => 'tenant',
'tenant_model_column' => null, // null = tenant key
'tenant_route_name_prefix' => 'tenant.',
'allowed_extra_model_columns' => [], // used with binding route fields
'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).

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:

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

Deleting a synced resource cleans up its pivot records

Section titled “Deleting a synced resource cleans up its pivot records”

Deleting any synced resource — central or tenant — now fires the new SyncedResourceDeleted event, handled by the DeleteResourceMapping listener which removes the resource’s records from the pivot table:

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

When a central resource is deleted, all of its pivot records are deleted. When a tenant resource is deleted, only the pivot record for that resource’s tenant is deleted.

There’s also an optional DeleteAllTenantMappings listener which deletes all pivot records of a deleted tenant. It’s commented out by default, since most applications handle this using onDelete('cascade') on the pivot table’s tenant foreign key:

app/Providers/TenancyServiceProvider.php
Events\TenantDeleted::class => [
JobPipeline::make([
Jobs\DeleteDatabase::class,
])/* ... */,
ResourceSyncing\Listeners\DeleteAllTenantMappings::class,
],

Since the used pivot tables can’t be detected automatically, the listener reads them from a static property that defaults to the polymorphic table used by default:

app/Providers/TenancyServiceProvider.php
ResourceSyncing\Listeners\DeleteAllTenantMappings::$pivotTables = [
'tenant_resources' => 'tenant_id',
];

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:

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

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',
];
}

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

Attaching and detaching works in both directions

Section titled “Attaching and detaching works in both directions”

Attaching a central resource to a tenant creates the tenant resource, and detaching it force deletes the tenant resource. In version 4, this works in both directions — $centralUser->tenants()->attach($tenant) and $tenant->users()->attach($centralUser) both do the same thing (the latter requires a properly configured pivot).

In version 4, mappings between synced resources and tenants are polymorphic by default. This means you can use a single pivot table (the default one is tenant_resources, published using php artisan vendor:publish --tag=resource-syncing-migrations) for all your synced resources, instead of creating separate pivot tables like tenant_users, tenant_products, etc.

The tenant_resources table has the tenant_resources_type column where we store the resource model’s class name. This allows multiple resource types to share the same table.

The records in tenant_resources will look like this:

tenant_idresource_global_idtenant_resources_type
tenant1fooApp\Models\CentralUser
tenant1barApp\Models\CentralProduct
tenant2bazApp\Models\CentralUser

In version 4, the ResourceSyncing trait provides the tenants() polymorphic relationship method. So unlike in version 3, you don’t need to define the relationship manually. You can still override the method and use the simpler belongsToMany relationship if you prefer.

The ResourceSyncing trait now provides a default getGlobalIdentifierKeyName() implementation returning global_id, so you only need to implement the method when you use a different column name.

Global identifier generation now lives in an overridable generateGlobalIdentifierKey() method, rather than being inlined in the trait’s creating hook. You can override it e.g. to use a different UniqueIdentifierGenerator for synced resources than for tenants:

ResourceSyncing.php
protected function generateGlobalIdentifierKey(): void
{
if (! app()->bound(UniqueIdentifierGenerator::class)) return;
$this->setAttribute(
$this->getGlobalIdentifierKeyName(),
app(UniqueIdentifierGenerator::class)->generate($this),
);
}

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.

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\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager::class,
],
],

Several improvements have been made to our SQLite support:

  • Tenant databases now get a .sqlite suffix if no suffix is configured.
  • Deleting a tenant database also deletes its -journal, -wal and -shm files.
  • Using :memory: in the template connection now gives each tenant a named in-memory database (file:_tenancy_inmemory_{tenant}?mode=memory&cache=shared) instead of a fresh, empty one on each connection, making in-memory SQLite actually usable with the package. Note that named in-memory databases only live for as long as there’s an open PDO connection to them — see in-memory SQLite databases.
  • There’s a new DisallowSqliteAttach feature which blocks the SQLite ATTACH statement, preventing connections to tenant databases from attaching other tenant databases. This is essentially analogous to the permission-controlled database managers we have for other database engines.

DatabaseTenancyBootstrapper::$harden is a new opt-in check that runs each time the bootstrapper is executed and verifies that the tenant’s connection doesn’t point at the central database or at another tenant’s database:

app/Providers/TenancyServiceProvider.php
DatabaseTenancyBootstrapper::$harden = true;

If it does, the connection is reverted back to central and a RuntimeException is thrown. It’s disabled by default — this isn’t a common issue, the check is meant as an optional security feature for applications with extremely sensitive data, and it costs a few queries on each initialization. See hardening the tenant connection.

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

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

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

v4 comes with the UrlGeneratorBootstrapper, which replaces Laravel’s URL generator to produce tenant-aware URLs in the tenant context:

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

It sets URL::defaults() for the 'tenant' parameter, so you don’t need to specify the tenant when generating a route to a tenant route. This is useful when using path identification:

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

It can also prefix route names, which is especially useful 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.

Both behaviors are configurable, and there are additional options for query string identification, route overrides, and bypassing the entire tenant-aware logic in individual route() calls. See the UrlGeneratorBootstrapper page for details.

There’s also the RootUrlBootstrapper which changes the root that routes are generated on (mainly useful in CLI contexts) rather than overriding route generation itself.

The tenants:run command now correctly handles stdin from subcommands and more complex arguments.

You can now use the php artisan tenant:tinker command:

Passing a tenant ID as an argument
$ php artisan tenant:tinker 1fecd0e5-cf2a-4aba-9115-d08cb016ff9c
Psy Shell v0.12.10 (PHP 8.4.11 — cli) by Justin Hileman
> tenant()->id;
= "1fecd0e5-cf2a-4aba-9115-d08cb016ff9c"
Interactively selecting a tenant
$ php artisan tenant:tinker
┌ Which tenant do you want to run Tinker as? ──────────────────┐
│ › ● First tenant (1fecd0e5-cf2a-4aba-9115-d08cb016ff9c) │
│ ○ Search by id │
│ ○ Search by domain │
└──────────────────────────────────────────────────────────────┘
Selecting a tenant using a domain
$ php artisan tenant:tinker
┌ Which tenant do you want to run Tinker as? ──────────────────┐
│ Search by domain │
└──────────────────────────────────────────────────────────────┘
┌ Enter the tenant domain: ────────────────────────────────────┐
│ acme │
└──────────────────────────────────────────────────────────────┘
Psy Shell v0.12.10 (PHP 8.4.11 — cli) by Justin Hileman
> tenant()->company;
= "Acme"

The command is essentially a shorthand for php artisan tinker and manually running tenancy()->initialize(), with the added convenience of being able to search a tenant either by ID or domain — or just using the first tenant to quickly get into some tenant context.

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),
],
Events\DeletingTenant::class => [
JobPipeline::make([
Jobs\DeleteDomains::class,
// Jobs\DeleteTenantStorage::class,
// Jobs\RemoveStorageSymlinks::class,
])->send(function (Events\DeletingTenant $event) {
return $event->tenant;
})->shouldBeQueued(false),
],

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/

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:

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)
--skip-tenants[=SKIP-TENANTS] The tenants to skip when running this command (multiple values allowed)
--with-pending[=WITH-PENDING] Include pending tenants in query if true/1, exclude if false/0. Defaults to the tenancy.pending.include_in_queries config value.

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;
}

tenancy()->reinitialize() ends tenancy and initializes it again for the same tenant. This is useful when you change the tenant’s attributes and need some bootstrapper to pick the change up.

Tenancy::$findWith lets you eager load relations when a tenant is fetched by identification middleware (or by a tenancy()->find() call that passes withRelations: true):

app/Providers/TenancyServiceProvider.php
Tenancy::$findWith = ['subscription'];

This matters most with cached identification: the resolvers cache the resolved tenant model, so relations loaded this way get cached along with it instead of being queried on every request. See finding tenants for more details.

v3 only shipped with a UUID generator. v4 adds several more, and moves them all to the Stancl\Tenancy\UniqueIdentifierGenerators namespace:

config/tenancy.php
'models' => [
'id_generator' => UniqueIdentifierGenerators\ULIDGenerator::class,
],

In addition to UUIDGenerator, you can now use UUIDv7Generator, ULIDGenerator, RandomIntGenerator (configurable $min/$max), RandomHexGenerator (configurable $bytes) and RandomStringGenerator (configurable $length). See Unique identifier generators for a comparison of the output lengths and unique combinations of each.

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.

config/tenancy.php
'database' => [
'drop_tenant_databases_on_migrate_fresh' => true,
]
Section titled “Added cookie support to InitializeTenancyByRequestData”

The InitializeTenancyByRequestData middleware can now identify tenants using cookies.

config/tenancy.php
'resolvers' => [
Resolvers\RequestDataTenantResolver::class => [
// Set any of these to null to disable that method of identification
'header' => 'X-Tenant',
'cookie' => 'tenant',
'query_parameter' => 'tenant',
'tenant_model_column' => null, // null = tenant key
'cache' => false,
'cache_ttl' => 3600, // seconds
'cache_store' => null, // null = default
],
// ...
],

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.

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

v4 makes several 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
  • You can use a custom impersonation token model
  • Expired and invalid tokens are now deleted, rather than only aborting the request
  • There’s a new tenants:purge-impersonation-tokens command for deleting tokens older than UserImpersonation::$ttl

The TenantConfig feature, which maps tenant attributes to config keys, has been replaced by the TenantConfigBootstrapper:

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

The feature was effectively a bootstrapper already — it listened to the TenancyBootstrapped and RevertedToCentralContext events itself. As a proper bootstrapper, it now gets the better error handling and safer reverting.

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 TenantConfigBootstrapper.

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).

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

All tenant commands now accept --skip-tenants, the opposite of --tenants:

Terminal window
php artisan tenants:migrate --skip-tenants=7c27cc0f-8ed6-4d2e-ac86-2ae9ac36acf5

Additionally, tenants:migrate-fresh now accepts --force for running the command in production, and the --database option of tenants:migrate is used for the template connection.

Support 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:

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'

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.

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.

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 support tagging, 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.

The bootstrapper can also scope sessions. Enabling tenancy.cache.scope_sessions prefixes your session’s cache store too, giving each tenant their own sessions. This only works with cache-based session drivers — the bootstrapper throws if it’s enabled with any other driver — so the default is derived from SESSION_DRIVER:

config/tenancy.php
'cache' => [
'scope_sessions' => in_array(env('SESSION_DRIVER'), ['redis', 'memcached', 'dynamodb', 'apc'], true),
],

If you use the file session driver, the equivalent is tenancy.filesystem.scope_sessions, which stores session files in the tenant’s storage directory.

In v3, QueueTenancyBootstrapper stayed in the job’s tenant context after the job was processed, and you had to enable its $forceRefresh static property to make each job see fresh tenant state.

In v4, the bootstrapper always initializes tenancy for the job being processed and reverts to the context afterwards, so changes to a tenant’s state are always picked up by the next job. The $forceRefresh property is gone.

The v3 behavior is still available in the new PersistentQueueTenancyBootstrapper. You need it when a JobProcessed listener has to run in the tenant context — e.g. Telescope’s JobWatcher, which unserializes the job and would fail on models referencing tenant connection. It requires more careful use, which is why it’s no longer the default. See the queue bootstrapper page for details.

v4 adds a bootstrapper for tenant-specific logging:

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

By default, it points Laravel’s single and daily channels at the tenant’s storage directory, so each tenant’s logs end up in their own folder. Channels that aren’t filesystem-based can be configured using $channelOverrides, which accepts either a map of tenant attributes to channel config keys, or a closure returning the full channel config:

app/Providers/TenancyServiceProvider.php
LogChannelBootstrapper::$channelOverrides = [
// maps $tenant->webhookUrl to the channel's url config
'slack' => ['url' => 'webhookUrl'],
];

For details, see the LogChannelBootstrapper page.

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',
]);
}
}

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.