Testing
Packstub Tenancy ships a TenantTestHelpers trait for writing tenant-aware tests in your own application, and the package itself carries two suites you can run: a dependency-free default suite that runs entirely on SQLite and a multi-server end-to-end suite that provisions real tenants across three real PostgreSQL server instances. This page covers how to set up your test harness, how to use the helpers, how to test database pool placement, and how to run the package's own suites.
Setting up your test harness
Two things make tenant tests fast and reliable: a sync queue and SQLite tenant databases.
Tenant provisioning (CreateDatabase → MigrateDatabase → MarkTenantReady) runs on the queue and is dispatched after the surrounding transaction commits. With QUEUE_CONNECTION=sync, the whole pipeline runs inline — by the time Tenant::create() returns, the tenant database exists, is migrated, and the tenant's status is ready. Set it in your phpunit.xml:
<php>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
</php>
Warning: Don't
Queue::fake()in tests that need a working tenant. A faked queue swallows the provisioning jobs, the tenant stays inprovisioningstatus forever, and theEnsureTenantIsReadymiddleware will redirect every panel request to the provisioning page.
With an SQLite template connection, Stancl's SQLiteDatabaseManager creates each tenant database as a .sqlite file — by default inside database_path(), where files collide across test runs and pollute your repository. Redirect them to a per-process temporary directory instead, and always end tenancy before teardown so the framework's rollback queries hit the central database, not a tenant database that may already be gone:
use Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager;
use Tests\TestCase as BaseTestCase;
abstract class TenantTestCase extends BaseTestCase
{
protected string $tenantDbPath;
protected function setUp(): void
{
parent::setUp();
// Isolate tenant SQLite files to a per-process temp dir so parallel
// and repeated runs never collide, and nothing leaks into database/.
$this->tenantDbPath = sys_get_temp_dir().'/tenant-tests-'.getmypid();
if (! is_dir($this->tenantDbPath)) {
mkdir($this->tenantDbPath, 0777, true);
}
SQLiteDatabaseManager::$path = $this->tenantDbPath;
}
protected function tearDown(): void
{
// Any test that entered a tenant leaves the default connection pointed
// at that tenant's database. Revert to the central context BEFORE the
// framework rolls anything back.
if (tenancy()->initialized) {
tenancy()->end();
}
foreach (glob($this->tenantDbPath.'/*') ?: [] as $file) {
@unlink($file);
}
parent::tearDown();
}
}
This is the same pattern the package's own tests/TestCase.php uses. SQLiteDatabaseManager::$path is a static property, so setting it in setUp() affects every tenant database created during that test.
If your app's tenant seeder touches app-specific tables, either leave packstub-tenancy.seeder at its null default in tests (the seed step is skipped entirely) or point it at a tenant-safe seeder.
The TenantTestHelpers trait
Add Packstub\Tenancy\Testing\TenantTestHelpers to any test class that needs tenants:
use Packstub\Tenancy\Testing\TenantTestHelpers;
use Tests\TenantTestCase;
class InvoiceTest extends TenantTestCase
{
use TenantTestHelpers;
public function test_an_owner_sees_their_invoices(): void
{
[$tenant, $user] = $this->createTenantWithUser();
$this->actingAsTenant($tenant, $user);
// You are now authenticated as $user, inside $tenant's database.
// Every Eloquent query on the default connection hits the tenant DB.
$this->leaveTenant();
}
}
In Pest, the trait works the same way — bind it with uses() and call the helpers on $this:
use Packstub\Tenancy\Testing\TenantTestHelpers;
uses(Tests\TenantTestCase::class, TenantTestHelpers::class);
it('creates ready tenants', function () {
$tenant = $this->createTenant(['name' => 'Acme', 'slug' => 'acme']);
expect($tenant->fresh()->status)->toBe('ready');
});
createTenant(array $attributes = [])
Creates a tenant using your configured packstub-tenancy.tenant_model — so if you point the config at your own model class, the helper returns instances of it. Defaults: name is Test Tenant, slug is test-tenant- plus a unique suffix; anything you pass in $attributes overrides the defaults. On a sync queue, the returned tenant is fully provisioned.
$tenant = $this->createTenant(['name' => 'Acme', 'slug' => 'acme']);
The helper also creates a domain record ({slug}.{central_domain}) for the tenant. It does this regardless of your identification mode — unlike the onboarding flow, which skips domain creation in path mode. This is a deliberate test convenience: the extra domain row is inert in path mode, and always creating it means the same helper works for subdomain-identified tests without branching. If a test asserts on your tenant's domain count in path mode, account for this row.
createTenantWithUser(array $tenantAttributes = [], array $userAttributes = [])
Creates a tenant, then a user via your configured auth.providers.users.model, and attaches the user to the tenant with the owner role. Returns a [$tenant, $user] pair for destructuring. User defaults: name Test User, a unique @example.com email, and password password.
[$tenant, $user] = $this->createTenantWithUser(
['name' => 'Acme'],
['email' => 'jane@example.com'],
);
actingAsTenant(Tenant $tenant, ?Authenticatable $user = null)
Authenticates as $user (when given) and enters the tenant's context via Stancl v4's $tenant->enter(). From this point the default database connection targets the tenant's database. Returns $this, so it chains with other test methods.
leaveTenant()
Ends tenancy (tenancy()->end()) and returns to the central context. Call it whenever a test needs to assert against central data after working inside a tenant — and rely on the base-class tearDown() shown above as the safety net for tests that forget.
Testing database pool placement
If you use horizontal scaling, you can test placement behavior without any real database servers: pool members are ordinary connections from config/database.php, so in-memory SQLite connections work fine. Define fake pool members and enable the pool in setUp():
use Packstub\Tenancy\Database\DatabasePool;
protected function setUp(): void
{
parent::setUp();
foreach (['tenant_pool_a', 'tenant_pool_b'] as $name) {
config()->set("database.connections.{$name}", [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
}
config()->set('packstub-tenancy.database_pool', [
'enabled' => true,
'connections' => ['tenant_pool_a', 'tenant_pool_b'],
'strategy' => DatabasePool::STRATEGY_LEAST_TENANTS,
'weights' => [],
]);
}
Then assert on the persisted assignment and the pool's distribution:
use Packstub\Tenancy\Database\DatabasePool;
use Packstub\Tenancy\Models\Tenant;
public function test_tenants_spread_evenly(): void
{
foreach (range(1, 4) as $i) {
Tenant::create(['name' => "T{$i}", 'slug' => "t{$i}"]);
}
$this->assertSame(
['tenant_pool_a' => 2, 'tenant_pool_b' => 2],
app(DatabasePool::class)->distribution(),
);
}
public function test_assignment_is_persisted(): void
{
$tenant = Tenant::create(['name' => 'Acme', 'slug' => 'acme'])->fresh();
$this->assertSame('tenant_pool_a', $tenant->getInternal('db_connection'));
}
The same pattern covers the edge cases worth pinning in your app:
- Scale-out: define a
tenant_pool_cconnection, append it topackstub-tenancy.database_pool.connections, create another tenant, and assert it lands on the new member. - Explicit pins: create a tenant with
'tenancy_db_connection' => 'residency_de'and assert the pool never reassigns it. - Databaseless tenants: create a tenant with
'tenancy_create_database' => falseand assertgetInternal('db_connection')staysnull. - Disabled pool: set
packstub-tenancy.database_pool.enabledtofalseand assert new tenants get no assignment.
The package's own tests/Feature/DatabasePoolAssignmentTest.php demonstrates all of these, including driving the pool through the fluent TenancyPlugin::make()->databasePool(...) API.
Running the package's own suites
Default suite
composer test
Runs the Unit and Feature suites entirely on SQLite with a sync queue. No external services are required. The multi-server E2E tests are part of the PHPUnit configuration but skip themselves unless the PACKSTUB_POOL_E2E=1 environment flag is set, so a plain composer test stays dependency-free.
To run a single test:
composer test:filter -- test_tenants_spread_evenly_under_least_tenants
Multi-server E2E suite
composer test:multi-server
This proves horizontal scaling against real infrastructure: it boots three independent PostgreSQL server instances (separate data directories, ports, and postmasters) via tests/bin/multi-server-pg.sh, runs the 5-test MultiServerE2E suite with PACKSTUB_POOL_E2E=1, then stops the servers — even if the suite fails. The suite verifies that:
- Tenants distribute across two real servers, each tenant's physical database existing on its own server and only there.
- Runtime connections hit the right server (asserted by port) and tenant data is isolated across servers.
- Adding a third server to the pool mid-test scales out: the next tenant provisions onto it and is immediately usable.
- Deleting a tenant drops its database from its own server and leaves other servers untouched.
tenants:pool --checkconfirms theCREATE DATABASEprivilege on the real servers.
You need PostgreSQL installed locally — the script uses pg_ctl from your PATH, or falls back to common install locations (Homebrew, Postgres.app, DBngin, Debian/Ubuntu paths). The servers run as superuser postgres with trust auth, listening on 127.0.0.1 only.
You can also manage the servers directly:
tests/bin/multi-server-pg.sh start # initdb on first run, then start all three
tests/bin/multi-server-pg.sh stop # stop all three
tests/bin/multi-server-pg.sh destroy # stop and delete the data directories
Ports and environment overrides
By default the three servers listen on ports 54331, 54332, and 54333, with data directories under /tmp/packstub-pool-pg. Everything is overridable:
| Variable | Consumed by | Default | Purpose |
|---|---|---|---|
PACKSTUB_POOL_PG_PORTS |
server script | "54331 54332 54333" |
Space-separated ports for the three servers |
PACKSTUB_POOL_PG_DIR |
server script | /tmp/packstub-pool-pg |
Base directory for the three data directories |
PACKSTUB_POOL_PG1_PORT |
test suite | 54331 |
Port the suite connects to for server 1 |
PACKSTUB_POOL_PG2_PORT |
test suite | 54332 |
Port the suite connects to for server 2 |
PACKSTUB_POOL_PG3_PORT |
test suite | 54333 |
Port the suite connects to for server 3 |
PACKSTUB_POOL_PG_HOST |
test suite | 127.0.0.1 |
Host the suite connects to |
PACKSTUB_POOL_PG_USER |
test suite | postgres |
Username the suite connects with |
PACKSTUB_POOL_PG_PASSWORD |
test suite | (empty) | Password the suite connects with |
PACKSTUB_POOL_E2E |
test suite | (unset) | Set to 1 to enable the E2E tests (set for you by composer test:multi-server) |
Note that the script and the suite read separate port variables. If you change ports, set both sides consistently:
PACKSTUB_POOL_PG_PORTS="55501 55502 55503" \
PACKSTUB_POOL_PG1_PORT=55501 \
PACKSTUB_POOL_PG2_PORT=55502 \
PACKSTUB_POOL_PG3_PORT=55503 \
composer test:multi-server
If a suite run fails with "Pool server [tenant_pool_N] is not reachable", the servers did not start (or ports don't match) — check server.log inside each data directory, or run tests/bin/multi-server-pg.sh destroy and start clean.