One of our customers was launching a subsidiary company and wanted to make their web application multi-tenant, so it could be used by the subsidiary company independently (and possibly by more of those in the future). I was tasked to make that a reality.
By the end of this article you should have an understanding of some of the challenges of making existing applications multi-tenant and means how those challenges can be overcome - with a mix of thorough research, good planning and tenancy package customization.
As always, I started with research. I like to understand the things I'll be working with well enough to make required changes confidently. There were two stages to my research:
- Review the existing code base
- Review the pool of possible solutions
This was a brownfield project and unfortunately the code quality was lacking. So not only I was about to make significant underlying infrastructural changes to the application, but do so while ensuring the software continues to serve its existing user base.
I have reviewed relevant parts of the code base and identified a number of aspects that would require my attention:
- domains
- database
- cache
- file storage
- queue worker
- search indexes
- mailing
- test setup
I then moved on to exploring possible options for making the web application multi-tenant. You only have a few options to go about it. To give an example related to usage of a database:
- Manually scope every query - meaning, adding a WHERE clause to filter on tenant_id, which is terrible idea that is error prone and provides no tenant data separation; it is literally asking for trouble.
- Use Eloquent Global Scopes - a bit better, but requires defining those on a number of Eloquent model classes, so still error prone (especially over project's lifetime) and provides no data separation; acceptable if for whatever valid reason you decide you want or have to stick with one database to back your multi-tenant web app.
- Use a dedicated database for each tenant - with just a bit of infrastructure overhead that option makes a lot of sense, because in software you merely have to take care of configuring the database connection based on the current tenant context and the code will simply continue to work.
There are a number of multi-tenancy Composer packages for the Laravel framework available, so it makes little sense to reinvent the wheel. I researched those and decided that Tenancy for Laravel would be a great fit. It was well maintained (including great documentation) and easily customizable, so I decided to move forward with it.
Tenancy for Laravel has a notion of a central application and a tenant application. The central application could host your landing and registration pages and host an admin panel to manage tenants, for example. The tenant application is executed in a tenant context, usually with the tenant's database, cache, etc.
In our case, there was no need for a notion of a central application, but we still needed it to initialize a specific tenant based on the request's domain. Simply put, our central application would have no front end, but would still control the list of tenants (which can be added via an Artisan CLI command) and where all requests initially reach.
Tenancy bootstrappers are executed when tenancy is initialized. Their responsibility is making Laravel features tenant-aware.
With that foundation, let's get to the meat of it!
Domains
The original application was served from foo.acme.com. The subsidy company would use foo.coyote.com. Both domains would be pointed at the same server, hosting one Laravel application instance. The initial request would then, based on the request domain, look up the tenant in the central database and initialize a tenant context. (Please note that load balancing and horizontal scaling are beyond the scope of this article.)
To facilitate quick cutover of the original foo.acme.com, the TTL of foo.acme.com was significantly decreased ahead of time, so by the time we're releasing multi-tenancy, the domain would switch over swiftly for all users. Afterwards, the domain TTL would be increased back up to facilitate DNS caching.
Since I needed to tell tenants apart based on the domain, I had to configure the value of tenancy.central_domains like the following, even if those domains were not relevant to ACME nor Coyote (at least until ACME decides to make this a full fledged SaaS):
'central_domains' => [ '127.0.0.1', 'localhost', 'staging.central.domain.com', 'production.central.domain.com', ],
Remember, in this specific case, we don't have a front-end for our central application.
Database
Tenancy for Laravel made configuring multi-tenant database really easy. However, the interesting part is how we got to the state we wanted in an existing application.
Separation of concerns
The central application is the one hosting database migrations. However, the existing ACME database would contain all of the tables as well as significant amount of data. For these reasons, the decision was pretty obvious - this database would not become the central database, it would become the ACME tenant database, whilst the migrations (and anything else tenant agnostic) would be moved to the central application database.
Deployment
It's worth mentioning that the deployment process, which in case of Laravel usually migrates just one database, needs to take care of migrating each tenant database as well. Tenancy for Laravel has an Artisan command for that. Here's a snippet from the Forge deployment script:
echo "Migrating central database..."
$FORGE_PHP artisan migrate --force
echo "Migrating tenant databases..."
$FORGE_PHP artisan tenants:migrate
Testing
I'll dedicate one section of this article later to specifically discuss how I approached unit testing while using Tenancy for Laravel.
Configuration
When a tenant is initialized, Tenancy for Laravel creates a database connection configuration dynamically at the following config key:
database.connections.tenant
It is worth mentioning that, unlike the second Coyote tenant, the existing ACME tenant had to be onboarded to the updated application without creating its database, as that one already existed. Here's how that can be done via CLI:
$acme = App\\Models\\Tenant::create([
'id' => 'acme',
'tenancy_create_database' => false,// <--- THIS is key!'tenancy_db_name' => 'acme-production',
'tenancy_db_username' => 'acme-user',
'tenancy_db_password' => 'the-password',
]);
$acme->domains()->create(['domain' => 'foo.acme.com']);
We still need to provide db name, username and password, so the database connection in the tenant context can be initialized, but the tenancy_create_database allows us to indicate we do not want Tenancy for Laravel to create the database for us.
After creating the tenant, we follow by creating the related domain.
Cache
This was as simple as making sure
Stancl\\Tenancy\\Bootstrappers\\CacheTenancyBootstrapper::class
is not commented in tenancy.php. Here's how I configured the prefixes:
'cache' => [
// This tag_base, followed by the tenant_id,
// will form a tag that will be applied on each cache call.
'tag_base' => 'tenant-',
],
'redis' => [
// Each key in Redis will be prepended by this prefix_base, followed by the tenant id.
'prefix_base' => 'tenant-',
'prefixed_connections' => [
'default',
],
],
File storage
This is where things get interesting. Out of the box I didn't find anything that would suite our needs. The decision was made to use DigitalOcean spaces to store files, so I implemented a custom boostrapper - a bit more convoluted than I'd like to also handle any storage_path() and asset() helper calls:
App\\Tenancy\\Bootstrappers\\FilesystemTenancyBootstrapper::class
Here's how it looks like:
<?php
declare(strict_types=1);
namespace App\\Tenancy\\Bootstrappers;
use Illuminate\\Foundation\\Application;
use Illuminate\\Support\\Facades\\Storage;
use Stancl\\Tenancy\\Contracts\\TenancyBootstrapper;
use Stancl\\Tenancy\\Contracts\\Tenant;
class FilesystemTenancyBootstrapper implements TenancyBootstrapper
{
protected Application $app;
public array $originalPaths = [];
// This method is intended for use in non-local environments.
public static function getTenantBucketName(string $tenantKey): string
{
// Note that DigitalOcean Spaces bucket names have to be globally unique!
return sprintf('central-%s-tenant-%s', app()->environment(), $tenantId);
}
public function __construct(Application $app)
{
$this->app = $app;
$this->originalPaths = [
'disks' => [],
'storage' => $this->app->storagePath(),
'asset_url' => $this->app['config']['app.asset_url'],
];
$this->app['url']->macro('setAssetRoot', function ($root) {
/* @phpstan-ignore-next-line */
$this->assetRoot = $root;
return $this;
});
}
public function bootstrap(Tenant $tenant): void
{
$suffix = $this->app['config']['tenancy.filesystem.suffix_base'] . $tenant->getTenantKey();
// storage_path()
if ($this->app['config']['tenancy.filesystem.suffix_storage_path'] ?? true) {
$this->app->useStoragePath($this->originalPaths['storage'] . "/{$suffix}");
}
// asset()
if ($this->app['config']['tenancy.filesystem.asset_helper_tenancy'] ?? true) {
if ($this->originalPaths['asset_url']) {
$this->app['config']['app.asset_url'] = $this->originalPaths['asset_url'] . "/$suffix";
/* @phpstan-ignore-next-line */
$this->app['url']->setAssetRoot($this->app['config']['app.asset_url']);
} else {
/* @phpstan-ignore-next-line */
$this->app['url']->setAssetRoot($this->app['url']->route('stancl.tenancy.asset', ['path' => '']));
}
}
$this->switchStorageBucket($tenant);
}
private function switchStorageBucket(Tenant $tenant): void
{
Storage::forgetDisk('do');
$this->app['config']['filesystems.disks.do.bucket'] = self::getTenantBucketName($tenant->getTenantKey());
}
public function revert(): void
{
$this->revertSwitchStorageBucket();
}
private function revertSwitchStorageBucket(): void
{
$this->app['config']['filesystems.disks.do.bucket'] = null;
}
}
Queue worker
As simple as uncommenting the following bootstrapper in your tenancy.php
Stancl\\Tenancy\\Bootstrappers\\QueueTenancyBootstrapper::class
One thing to be careful in unit tests - always explicitly fake your events, because Tenancy for Laravel hooks into Dispatcher events to correctly handle tenant context when executing queued jobs. In short, don't simply Bus::fake() with no arguments.
Search indexes
The project uses Laravel Scout backed by Algolia. This time the custom bootstrapper was much simpler to implement:
App\\Tenancy\\Bootstrappers\\ScoutTenancyBootstrapper::class,
Here's how it looks like. Note how ACME did not have search index prefix, so it's getting a bit of special treatment.
<?php
namespace App\\Tenancy\\Bootstrappers;
use Stancl\\Tenancy\\Contracts\\TenancyBootstrapper;
use Stancl\\Tenancy\\Contracts\\Tenant;
class ScoutTenancyBootstrapper implements TenancyBootstrapper
{
private const SCOUT_PREFIX_CONFIG_KEY = 'scout.prefix';
private ?string $originalPrefix = null;
public function bootstrap(Tenant $tenant): void
{
$this->originalPrefix = config(self::SCOUT_PREFIX_CONFIG_KEY);
// This is because existing ACME search indexes do not have a prefix
if ($tenant->getTenantKey() === 'acme') {
config([self::SCOUT_PREFIX_CONFIG_KEY => '']);
return;
}
config([self::SCOUT_PREFIX_CONFIG_KEY => $tenant->getTenantKey() . '_']);
}
public function revert(): void
{
config([self::SCOUT_PREFIX_CONFIG_KEY => $this->originalPrefix]);
}
}
Mailing
For the mail from address and name the mapConfig() method from the TenancyServiceProvider can be used to map the data JSON column values from the tenants table to the config. In this case, the mapping would look as follows:
protectedfunctionmapConfig():void
{
TenantConfig::$storageToConfigMap = [
'app_url' => 'app.url',
'app_name' => 'app.name',
// ...'mail_from_address' => 'mail.from.address',
'mail_from_name' => 'mail.from.name',
];
}
Test setup
This is the fun part. I wanted to fit the unit tests in the multi-tenancy context with minimal impact on great developer experience. Sufficient for most test cases, I assumed we have a central application (database) and one tenant application (tenant database).
For this purpose, I customized the framework default LazilyRefreshDatabase trait and the RefreshDatabase trait.
We want to make use of lazy database refresh, but we also want to migrate the tenant database. For this reason, our custom LazilyRefreshDatabase trait will be calling the baseRefreshDatabase method.
Here it is in its entirety. I have indicated the two key lines for your convenience:
<?phpnamespaceTests\\Traits;
useIlluminate\\Foundation\\Testing\\RefreshDatabaseState;
trait LazilyRefreshDatabase
{
useRefreshDatabase {
refreshTestDatabaseasbaseRefreshDatabase;// <-- THIS is key
}
/**
* Define hooks to migrate the database before and after each test.
*
*/publicfunctionrefreshDatabase():void
{
$database =$this->app->make('db');
$database->beforeExecuting(function () {
if (RefreshDatabaseState::$lazilyRefreshed) {
return;
}
RefreshDatabaseState::$lazilyRefreshed = true;
$this->baseRefreshDatabase();// <-- THIS is key
});
$this->beforeApplicationDestroyed(function () {
RefreshDatabaseState::$lazilyRefreshed = false;
});
}
}
Now that we're calling into our custom RefreshDatabase trait, let's have a closer look at it. It is heavily inspired by the
Illuminate\\Foundation\\Testing\\RefreshDatabase
trait, with the required additions for the tenant database. Note there's no mention of in-memory (SQLite) database, because we like our database systems to match between development and production environments for multiple reasons (that are out of scope for this article).
We have added the following static property to track the tenant migration state:
public static bool $tenantMigrated = false;
They key change is in the refreshTestDatabase method:
protectedfunctionrefreshTestDatabase()
{
if (!RefreshDatabaseState::$migrated) {
$this->artisan('migrate:fresh',$this->migrateFreshUsing());
if (!self::$tenantMigrated) {// <-- THIS is key$this->artisan('migrate:fresh', [
'--database' => 'testing_tenant',
'--path' => 'database/migrations/tenant/',
]);
self::$tenantMigrated = true;
}
$this->app[Kernel::class]->setArtisan(null);
RefreshDatabaseState::$migrated = true;
$this->createTenant();// <-- THIS is key
}
$this->initializeTenant();// <-- THIS is key$this->beginDatabaseTransaction();// This is also custom
}
When the central database is migrated, we also make sure to migrate the tenant database by passing --database and --path arguments to the migrate:fresh command.
We then initializeTenant, which calls the appropriate package helper:
tenancy()->initialize(Tenant::query()->where('id', $tenantKey)->firstOrFail());
We also have to ensure to customize the return value of connectionsToTransact method, so we don't use transactions in the central database . We return [null], which is the framework default, we will manually control the tenant database transaction later.
To be sure you can follow along and have all pieces in place, here it is in its entirety. Some methods have been edited out, when they would be the same as the orginal framework trait.
<?phpnamespaceTests\\Traits;
useIlluminate\\Contracts\\Console\\Kernel;
useIlluminate\\Foundation\\Testing\\DatabaseTransactionsManager;
useIlluminate\\Foundation\\Testing\\RefreshDatabaseState;
useIlluminate\\Foundation\\Testing\\Traits\\CanConfigureMigrationCommands;
trait RefreshDatabase
{
useCanConfigureMigrationCommands;
useInteractsWithTenant;
public staticbool $tenantMigrated = false;
publicfunctionsetUpRefreshDatabase():void
{
$this->beforeRefreshingDatabase();
$this->refreshTestDatabase();
$this->afterRefreshingDatabase();
}
/**
* @return void
*/protectedfunctionrefreshTestDatabase()
{
if (!RefreshDatabaseState::$migrated) {
$this->artisan('migrate:fresh',$this->migrateFreshUsing());
if (!self::$tenantMigrated) {
$this->artisan('migrate:fresh', [
'--database' => 'testing_tenant',
'--path' => 'database/migrations/tenant/',
]);
self::$tenantMigrated = true;
}
$this->app[Kernel::class]->setArtisan(null);
RefreshDatabaseState::$migrated = true;
$this->createTenant();
}
$this->initializeTenant();
$this->beginDatabaseTransaction();
}
protectedfunctionconnectionsToTransact():array
{
return [null];// <-- Customized return value
}
// No changes to other RefreshDatabase trait methods
}
The InteractsWithTenant trait encapsulates some tenant-specific methods referenced from the snippet above. Here it is:
<?phpdeclare(strict_types=1);
namespaceTests\\Traits;
useApp\\Models\\Tenant;
useIlluminate\\Support\\Facades\\Artisan;
trait InteractsWithTenant
{
protectedfunctiongivenCurrentTenantIs(string $tenantKey):void
{
$this->createTenant($tenantKey);
$this->initializeTenant($tenantKey);
$this->beginDatabaseTransaction();
Artisan::call('tenants:seed', [
'--tenants' => [$tenantKey],
]);
}
protectedfunctioncreateTenant(string $tenantKey = 'testing'):void
{
if (Tenant::query()->where('id', $tenantKey)->exists()) {
return;
}
/** @var Tenant $tenant */
$tenant = Tenant::query()
->create([
'id' => $tenantKey,
'tenancy_db_name' => config('database.connections.testing_tenant.database'),
'tenancy_db_username' => config('database.connections.testing_tenant.username'),
'tenancy_db_password' => config('database.connections.testing_tenant.password'),
'tenancy_create_database' => false,
'mail_from_address' => "info@$tenantKey.localhost",
'mail_from_name' => 'Testing',
]);
$tenant->domains()->create(['domain' => $tenantKey . '.localhost']);
}
protectedfunctioninitializeTenant(string $tenantKey = 'testing'):void
{
tenancy()->initialize(Tenant::query()->where('id', $tenantKey)->firstOrFail());
}
}
Note how the givenCurrentTenantIs method starts a transaction on this tenant database (and optionally runs the seeder, depending on your requirements).
Closing words
And that's it! I hope you have picked up a thing or two from this article regarding how you can configure an existing Laravel application to become multi-tenant. On this specific project, the biggest challenge was identifying all the pieces that require multi-tenancy without prior knowledge of the code base and then carefully applying required changes, supporting myself with the existing unit tests as well as some new tests specifically added for this set of changes.
Member discussion