Testing Your Multi-database Multi-tenant Laravel Application
For some months I had been working on a multi-tenant loan management system using the Tenancy for Laravel package. Following their documentation, everything worked perfectly in production — the only issue was getting feature tests to run reliably.
The testing section of their docs suggests initialising tenancy in setUp() and relying on RefreshDatabase. This failed consistently and made me want to pull my hair out. After hours of trying, I landed on a pattern that works — and over time, it evolved considerably from that first working version.
The naive first version (and why it breaks)
The initial instinct is to create a fresh tenant in setUp() and delete it in tearDown():
abstract class TenantAppTestCase extends BaseTestCase
{
use RefreshDatabase;
protected Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
$this->initializeTenancy();
$this->withoutMiddleware([
InitializeTenancyByDomain::class,
PreventAccessFromCentralDomains::class,
]);
}
protected function tearDown(): void
{
$this->tenant->delete();
parent::tearDown();
}
protected function initializeTenancy(): void
{
$this->tenant = Tenant::create();
tenancy()->initialize($this->tenant);
}
}
This works — barely. The problems:
RefreshDatabaseis slow. It drops and recreates all databases between every test. On a multi-database setup this means recreating both the central and tenant databases hundreds of times per run.- Creating and deleting a tenant per test is expensive. It spins up a new database, runs all migrations, then tears it all down again.
- It does not scale to parallel test runs — concurrent tenant creation causes race conditions and lock collisions.
The evolved version
After months of running this in a real system, here is what the base class looks like now:
<?php
declare(strict_types=1);
namespace Tests;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Override;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains;
use Tests\Helpers\Models\Tenant;
abstract class TenantAppTestCase extends BaseTestCase
{
use DatabaseTransactions;
private bool $tenantTransactionStarted = false;
protected Tenant $tenant;
protected User $user;
protected Role $role;
protected Branch $branch;
#[Override]
protected function setUp(): void
{
parent::setUp();
$this->initializeTenancy();
$this->withoutMiddleware([
InitializeTenancyByDomain::class,
PreventAccessFromCentralDomains::class,
]);
// DatabaseTransactions wraps the central DB connection but NOT the tenant
// connection — start a tenant transaction manually so each test rolls back
// its own tenant DB writes cleanly.
$this->beginTenantDatabaseTransaction();
$this->init();
}
protected function initializeTenancy(): void
{
$tenant = Tenant::orderBy('created_at', 'desc')->first();
if (! $tenant) {
throw new \Exception('Test tenant not found. Run: php artisan app:create-automated-tests-tenant');
}
$this->tenant = $tenant;
tenancy()->initialize($this->tenant);
}
protected function init(): void
{
$tenantKey = $this->tenant->getTenantKey();
// Cache Branch and Level once per tenant per process — avoids redundant
// queries across hundreds of tests.
$this->branch = Cache::store('array')->rememberForever(
"test.{$tenantKey}.branch",
fn() => Branch::first() ?? Branch::factory()->create(),
);
// Create a fresh user per test with a unique email to avoid collisions,
// especially in parallel runs.
$this->user = User::factory()->create([
'email' => \Str::uuid7()->toString() . '@example.com',
'branch_id' => $this->branch->id,
]);
$permissions = Cache::store('array')->rememberForever(
"test.{$tenantKey}.permissions",
fn() => Permission::all(),
);
$this->role = Role::create(['name' => 'test_admin_' . \Str::uuid()->toString(), 'guard_name' => 'web']);
$this->role->givePermissionTo($permissions);
$this->user->assignRole($this->role);
}
#[Override]
protected function tearDown(): void
{
$this->rollbackTenantDatabaseTransaction();
tenancy()->end();
unset($this->user, $this->role, $this->tenant);
parent::tearDown();
}
private function beginTenantDatabaseTransaction(): void
{
DB::connection('tenant')->beginTransaction();
$this->tenantTransactionStarted = true;
}
private function rollbackTenantDatabaseTransaction(): void
{
if (! $this->tenantTransactionStarted) {
return;
}
$connection = DB::connection('tenant');
while ($connection->transactionLevel() > 0) {
$connection->rollBack();
}
$this->tenantTransactionStarted = false;
}
}
Your test classes extend it the same way:
namespace Tests\Feature;
use Tests\TenantAppTestCase as TestCase;
class LoansControllerTest extends TestCase
{
// $this->user, $this->role, $this->tenant, $this->branch
// are all available and rolled back after every test
}
What changed and why
DatabaseTransactions instead of RefreshDatabase
DatabaseTransactions wraps each test in a transaction and rolls it back at the end — no schema recreation. This is dramatically faster on a multi-database setup.
Tenant DB transaction managed manually
This is the critical insight: DatabaseTransactions only wraps the default database connection. The tenant database runs on a separate connection (tenant), so DatabaseTransactions ignores it entirely. You have to beginTransaction() and rollBack() on the tenant connection yourself. The while ($connection->transactionLevel() > 0) loop handles cases where application code nested additional transactions during the test.
No tenant creation in setUp — use a pre-existing one
Instead of creating and dropping a tenant database on every test run, create one fixed test tenant using an Artisan command once:
php artisan app:create-automated-tests-tenant
Then initializeTenancy() just looks it up. This moves tenant provisioning out of the hot path entirely — and means you can run your full test suite without touching your tenant migration stack on every test.
tenancy()->end() in tearDown
Always call tenancy()->end() in tearDown(). Without it, tenancy state leaks between tests — the next test may start with the wrong tenant context still active.
Fixture caching via Cache::store('array')
Shared fixtures like Branch, Level, and Permissions are queried once per process and reused across all tests via the in-memory array cache. User and Role are created fresh per test (with UUID-based names/emails) to avoid collisions in parallel runs.
The documentation told me to call tenancy()->initialize() in setUp() — that part was right. Everything else needed to be worked out the hard way.