Home About Projects Skills Writing Contact
← All writing
Sep 20, 2023 5 min read · Laravel· PHP· Testing· Multi-tenancy

Testing Your Multi-database Multi-tenant Laravel Application

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:

  • RefreshDatabase is 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.

Bright Nkrumah
Senior software engineer in Kumasi, Ghana. Twelve years of writing PHP, and counting.
More writing