DEV Community

Abderrahim El Ouariachi
Abderrahim El Ouariachi

Posted on

Making stancl/tenancy tests 3x faster in Laravel

If you are using tenancy with a Laravel application, this post is for you.

In this post, I will talk about the method I found to speed up a tenancy test suite by 3x.

Tools: Laravel 13, Pest 5, stancl/tenancy v4

TL;DR

Create one tenant (and its database) per test file instead of one per test, wrap each test in transactions on the central and tenant connections, roll back and disconnect after each test, and drop the tenant and its database in a shutdown function once the run is over.

Why running tenancy tests becomes a nightmare

In an application that uses tenancy, the test suite usually gets very slow very quickly. This happens because each tenant needs its own database, and that database must be migrated with all the tenant tables. This happens automatically, of course, if you are using a package like stancl/tenancy. So when you do:

Tenant::factory()->create();
Enter fullscreen mode Exit fullscreen mode

you are not just creating a tenant row, but also building its database and running its migrations.

How does this happen under the hood?

When you create a tenant, a TenantCreated event is dispatched, and multiple jobs are registered for that event. This is evident in the TenancyServiceProvider.php file, where every event and its respective jobs are listed:

        Events\TenantCreated::class => [
                JobPipeline::make([
                    Jobs\CreateDatabase::class,
                    Jobs\MigrateDatabase::class,
                               ])->send(function (Events\TenantCreated $event) {
                    return $event->tenant;
                })->shouldBeQueued(),
            ],
Enter fullscreen mode Exit fullscreen mode

As you can see, two jobs are dispatched: CreateDatabase and MigrateDatabase. You can infer each one's role from its name: one creates the database and the other migrates it. This is the part that takes time to run. If you create a tenant for each test, you will be creating and migrating a database for every test, so if you have 50 tests, that becomes 50 databases.

Note: the jobs are marked with shouldBeQueued(), but in the test environment the queue runs synchronously (QUEUE_CONNECTION=sync), so the test waits for the database to be created and migrated before it continues.

The result

Measured on 111 tests, using an i5 processor with 8GB RAM.

Before After Speedup Time saved
Sequential 87s 26s 3.35x 61s (70% less)
Parallel (8 processes) 45s 16s 2.81x 29s (64% less)

That is roughly a 3x speedup, or 70% less time spent running the suite. The rest of this post explains how to get there.

How to prepare the database for each test

An example with Pest would be:

beforeEach(function () {
    $this->tenant = Tenant::factory()->create();

    tenancy()->initialize($this->tenant);
});

afterEach(function () {
    tenancy()->end();

    $this->tenant->delete();
});

test('Test One', function () {});

test('Test Two', function () {});
Enter fullscreen mode Exit fullscreen mode

In this case, two databases will be created. First, we will improve the code a little so we can reuse it across all our tenant tests. For that, we will use a trait, like the following:


namespace Tests;

use App\Models\Tenant;
use Illuminate\Support\Facades\DB;

trait HandleTenancy
{
    public Tenant $tenant;

    public function setUpTenancy(): void
    {
        $this->tenant = Tenant::factory()->create();

        tenancy()->initialize($this->tenant);
    }

    public function tearDownTenancy(): void
    {
        tenancy()->end();

        $this->tenant->delete();

        DB::purge('tenant');
        DB::purge('pgsql');
    }
}
Enter fullscreen mode Exit fullscreen mode

Register the trait in the Pest file:


pest()->extend(TestCase::class)
    ->use(HandleTenancy::class)
    ->in('Tenant');
Enter fullscreen mode Exit fullscreen mode

Make sure your tests are separated from your central application tests. In my case, I use a Tenant directory where I put all tests related to tenancy. Also make sure you are not using the RefreshDatabase trait, as it would delete the tenant without deleting its database.

Then you can do the following in your tests:


beforeEach(function () {
    $this->setUpTenancy();

   // Prepare some other model
});

afterEach(function () {
    $this->tearDownTenancy();
});

test('Test One', function () {});

test('Test Two', function () {});
Enter fullscreen mode Exit fullscreen mode

Two databases are still being created, but now we have a unified place to make changes: the HandleTenancy trait.

If you want to improve this further, you can call the methods in the Pest file for each test, like this:

pest()->extend(TestCase::class)
    ->use(HandleTenancy::class)
    ->beforeEach(fn () => $this->setUpTenancy())
    ->afterEach(fn () => $this->tearDownTenancy())
    ->in('Tenant');
Enter fullscreen mode Exit fullscreen mode

With this, you no longer need to call them in each test file.

How to improve the speed

As we said earlier, the problem arises because creating a tenant also creates its database and runs the migrations, which slows us down.

So, what should we do?

Reuse the tenant and its database.

But how?

It's easy, really. The Laravel framework already allows for this through transactions.

So we need to solve the following problems:

  • Instead of creating a tenant for each test, reuse the first tenant created.
  • Manage connections, because if we don't, multiple database errors will arise, especially if we are using parallelization.
  • If we reuse the tenant, we need a way to delete the tenant and its database at the end, once all the tests have completed.

Reusing the tenant

In a normal Laravel tenancy application, we have two connections: the central application connection (in my case, PostgreSQL, pgsql) and another for the tenant connection, named tenant. For each test, the framework creates new connections. If we run the tests sequentially, this is not a problem, but when running them in parallel, multiple connections are created and quickly exhaust the number of simultaneous connections we can make to the database. In my case, with PostgreSQL, the limit is set at 97 for regular users, and three connections are reserved for administrators, which we cannot normally use.

So we need to take this into account when we create the tearDownTenancy method.

At the beginning, our HandleTenancy trait will look like this:


namespace Tests;

use App\Models\Tenant;
use Illuminate\Support\Facades\DB;


trait HandleTenancy
{
    public function setUpTenancy(): void
    {
    }

    public function tearDownTenancy(): void
    {
        DB::connection('tenant')->rollBack();
        DB::connection('tenant')->disconnect();

        tenancy()->end();

        DB::connection('pgsql')->rollBack();
        DB::connection('pgsql')->disconnect();
    }
}
Enter fullscreen mode Exit fullscreen mode

This is how we should manage the teardown of the tenancy. Instead of deleting the tenant, we roll back the database and close the connections after each test, so they don't build up and cause the problems we talked about before.

Next, we will reuse the tenant. We need to create a static variable that stores the tenant's ID so we can reuse it in each test. If $sharedTenantId is not null, that means we are not in the first test and the tenant should not be created. Otherwise, we create the tenant.

Note: a static property declared in a trait belongs to each class that uses the trait, not to the whole PHP process. Pest compiles each test file into its own class, so you get one shared tenant per test file rather than one for the entire suite. That is still a big win. If you want a single tenant per process, keep the static property in a separate class and read it from the trait.


namespace Tests;

use App\Models\Tenant;
use Illuminate\Support\Facades\DB;


trait HandleTenancy
{
    protected static ?string $sharedTenantId = null;


    public function setUpTenancy(): void
    {
        if (static::$sharedTenantId !== null) {
            return;
        }

        $tenant = Tenant::factory()->create();

        static::$sharedTenantId = $tenant->id;
    }

    public function tearDownTenancy(): void
    {
        DB::connection('tenant')->rollBack();
        DB::connection('tenant')->disconnect();

        tenancy()->end();

        DB::connection('pgsql')->rollBack();
        DB::connection('pgsql')->disconnect();
    }
}

Enter fullscreen mode Exit fullscreen mode

Now we should think about how to use transactions with all this logic. We can do it very simply: make sure you create the tenant before beginning the transaction.


namespace Tests;

use App\Models\Tenant;
use Illuminate\Support\Facades\DB;


trait HandleTenancy
{
    protected static ?string $sharedTenantId = null;


    public function setUpTenancy(): void
    {
        if (static::$sharedTenantId === null) {
            $tenant = Tenant::factory()->create();

            static::$sharedTenantId = $tenant->id;
        }

        DB::connection('pgsql')->beginTransaction();

        tenancy()->initialize(
            Tenant::query()->findOrFail(static::$sharedTenantId)
        );

        DB::connection('tenant')->beginTransaction();
    }

    public function tearDownTenancy(): void
    {
        DB::connection('tenant')->rollBack();
        DB::connection('tenant')->disconnect();

        tenancy()->end();

        DB::connection('pgsql')->rollBack();
        DB::connection('pgsql')->disconnect();
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we are using transactions and managing the connections well, but we have another issue to take care of: the tenant and its database are not deleted after each test suite run. We reuse a single database per test file, but that same database is not deleted at the end, which will cause databases to build up after multiple test runs. To resolve this, PHP provides a special function called register_shutdown_function, which allows us to define a function that is called before the PHP process shuts down.

We will call it inside the if clause in the setUpTenancy method. When the function runs, the Laravel container will not be available, so we need to pass all the required values as parameters.

    public function setUpTenancy(): void
    {
        if (static::$sharedTenantId === null) {
            $tenant = Tenant::factory()->create();

            static::$sharedTenantId = $tenant->id;

            register_shutdown_function(
                self::dropTenant(...),
                $tenant->id,
                $tenant->database()->getName(),
                config('database.connections.pgsql'),
            );
        }

        DB::connection('pgsql')->beginTransaction();

        tenancy()->initialize(
            Tenant::query()->findOrFail(static::$sharedTenantId)
        );

        DB::connection('tenant')->beginTransaction();
    }

    private static function dropTenant(string $tenantId, string $databaseName, array $config): void
    {
        try {
            $pdo = new PDO(
                "pgsql:host={$config['host']};port={$config['port']};dbname={$config['database']}",
                $config['username'],
                $config['password'],
            );

            $pdo->exec('DELETE FROM tenants WHERE id = '.$pdo->quote($tenantId));
            $pdo->exec('DROP DATABASE IF EXISTS "'.$databaseName.'"');
        } catch (Throwable $e) {
            fwrite(STDERR, "Tenant cleanup failed: {$e->getMessage()}\n");
        }
    }
Enter fullscreen mode Exit fullscreen mode

Now the database gets deleted after each run.

After refactoring, you should end up with the following piece of code:

<?php

namespace Tests;

use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
use PDO;
use Throwable;

trait HandleTenancy
{
    private const string CENTRAL_CONNECTION = 'pgsql';

    private const string TENANT_CONNECTION = 'tenant';

    protected static ?string $sharedTenantId = null;

    public function setUpTenancy(): void
    {
        $this->ensureSharedTenantExists();

        DB::connection(self::CENTRAL_CONNECTION)->beginTransaction();

        tenancy()->initialize(
            Tenant::query()->findOrFail(static::$sharedTenantId)
        );

        DB::connection(self::TENANT_CONNECTION)->beginTransaction();
    }

    public function tearDownTenancy(): void
    {
        $this->rollBackAndDisconnect(self::TENANT_CONNECTION);

        tenancy()->end();

        $this->rollBackAndDisconnect(self::CENTRAL_CONNECTION);
    }

    private function ensureSharedTenantExists(): void
    {
        if (static::$sharedTenantId !== null) {
            return;
        }

        $tenant = Tenant::factory()->create();

        static::$sharedTenantId = $tenant->id;

        // stancl only needs this connection while creating the database.
        DB::purge(config('tenancy.database.tenant_host_connection_name'));

        // Pass plain values only: the container is already gone at shutdown.
        register_shutdown_function(
            self::dropTenant(...),
            $tenant->id,
            $tenant->database()->getName(),
            config('database.connections.'.self::CENTRAL_CONNECTION),
        );
    }

    private function rollBackAndDisconnect(string $connection): void
    {
        DB::connection($connection)->rollBack();
        DB::connection($connection)->disconnect();
    }

    private static function dropTenant(string $tenantId, string $databaseName, array $config): void
    {
        try {
            $pdo = new PDO(
                "pgsql:host={$config['host']};port={$config['port']};dbname={$config['database']}",
                $config['username'],
                $config['password'],
            );

            $pdo->exec('DELETE FROM tenants WHERE id = '.$pdo->quote($tenantId));
            $pdo->exec('DROP DATABASE IF EXISTS "'.$databaseName.'"');
        } catch (Throwable $e) {
            fwrite(STDERR, "Tenant cleanup failed: {$e->getMessage()}\n");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

What about parallel runs?

When you run the tests in parallel, each worker is its own PHP process. This means each worker creates its own shared tenant (and database), runs its tests against it, and registers its own shutdown function to clean up. Since we roll back and disconnect after every test, the number of open connections stays low, and workers never share a tenant or step on each other's data.

Caveats

This approach is not a fit for every test, so keep these in mind:

  • Tests that depend on committed data. Everything runs inside a transaction that is rolled back, so anything that needs data to be actually committed will not work.
  • Multiple connections and queued jobs. If your code uses a different connection, or a queued job runs on another connection, it will not see the uncommitted data from the transaction.
  • Tests for tenant creation itself. If you are testing the creation or deletion of a tenant, you still need the old per-test approach, so keep those tests outside of this trait.
  • Sequences do not roll back in PostgreSQL. Auto-increment IDs will keep growing between tests, so never assert on a specific ID.
  • The shutdown function is not bulletproof. If the process is killed (kill -9, a CI timeout, and so on), register_shutdown_function will not run, and a database can be left behind from time to time. It is worth cleaning those up occasionally.
  • DROP DATABASE fails if connections are still open. If you run into this, PostgreSQL 13 and later supports forcing the drop:
$pdo->exec('DROP DATABASE IF EXISTS "'.$databaseName.'" WITH (FORCE)');
Enter fullscreen mode Exit fullscreen mode

Top comments (0)