Packstub.

Horizontal scaling

Packstub Tenancy can load-balance new tenant databases across a pool of database servers. Each pool member is an ordinary named connection in config/database.php pointing at a different server; when a tenant signs up, the plugin picks a member using a placement strategy and persists the choice on the tenant. From that moment, provisioning, runtime connections, migrations, and deletion all happen on that tenant's own server — and adding capacity to your SaaS is one connection definition plus one line of config.

This guide describes the pool under the default dedicated database strategy, where members are database servers and each tenant's database is created on one of them. Under the shared strategy the same pool, strategies, and tooling place tenants across pre-provisioned shared shard databases instead — see Sharded shared for the differences.

Why horizontal scaling

Every one-server multi-database SaaS hits the same ceiling. Databases-per-tenant is a great isolation model, but a single database server has finite connections, finite IOPS, finite disk, and — on managed platforms — a hard cap on the number of databases it will comfortably host. When tenant 300 signs up, your only options on one server are a bigger machine (vertical scaling, with a ceiling of its own) or a migration project.

The database pool removes that ceiling. It decides placement: each new tenant's database is created on one of several servers, chosen by strategy (fewest tenants by default). The pool never routes requests at runtime — stancl/tenancy already resolves every tenant's server from a single persisted attribute — so there is no proxy, no extra hop, and no shared point of failure between tenant databases.

                     new tenant signups
                            |
                            v
                 +----------------------+
                 |     DatabasePool     |
                 |   (placement only)   |
                 +----+------------+----+
                      |            |
        persists tenancy_db_connection per tenant
                      |            |
                      v            v
          +---------------+  +---------------+       +---------------+
          |   Server A    |  |   Server B    |  ...  |   Server C    |
          | tenant_pool_1 |  | tenant_pool_2 |       | tenant_pool_3 |
          |    100 DBs    |  |    100 DBs    |       |  (just added: |
          +---------------+  +---------------+       |  fills first) |
                                                     +---------------+

Need more capacity? Provision another server, add its connection to config/database.php, append the name to the pool, deploy. The default least-tenants strategy immediately favors the empty server until the pool evens out.

How it works

The entire mechanism rests on one attribute that stancl/tenancy already understands: the per-tenant tenancy_db_connection, stored in the tenant's data column. It names the template connection — the config/database.php entry whose host, port, and credentials describe the tenant's server. Everything that touches a tenant database resolves the server from it:

  • Provisioning — the queued CreateDatabase and MigrateDatabase jobs connect to the tenant's server to create and migrate its database.
  • Runtime — stancl's DatabaseTenancyBootstrapper builds the live tenant connection from the template, so every query inside tenant context hits the right server.
  • Maintenancetenants:migrate and friends iterate tenants and follow each tenant's own connection.
  • DeletionDeleteDatabase drops the database on the tenant's server, and only there.

The pool's job is to write that attribute once. Assignment runs from the tenant model's creating hook, registered by the plugin's service provider — so every creation path is covered: the Filament onboarding page, Tenant::create() in Tinker or a seeder, your test suite, and stancl's pending-tenant pool alike. Because the hook runs before the INSERT, the connection is committed together with the tenant row and is already visible when the TenantCreated provisioning pipeline — dispatched only after the surrounding transaction commits — reaches a worker.

Two kinds of tenants are deliberately skipped:

  • Tenants that already carry an explicit tenancy_db_connection — a pin always wins (see Pin a tenant to a server).
  • Tenants created with stancl's tenancy_create_database => false flag — under the dedicated strategy they have no database to place. (Under the shared strategy these are exactly the tenants the pool places — onto shard databases rather than servers.)

Placement is sticky. The attribute is written once, at creation, and nothing in the plugin ever rewrites it: strategies only affect future tenants, failed provisioning retries re-run on the originally assigned server, and pins are never rebalanced. A tenant's data never silently moves.

Set up a pool

1. Define one connection per server

Each pool member is a normal Laravel connection pointing at a tenant database server: host, port, admin credentials that can CREATE DATABASE, and an existing maintenance database on that server (provisioning connects to it before the tenant's database exists). On PostgreSQL the built-in postgres database is the natural choice.

A complete two-server PostgreSQL example for config/database.php:

'connections' => [

    // ...

    'tenant_pool_1' => [
        'driver' => 'pgsql',
        'host' => env('TENANT_POOL_1_HOST', '10.0.1.10'),
        'port' => env('TENANT_POOL_1_PORT', 5432),
        // An existing database on this server. Provisioning connects to it
        // to run CREATE DATABASE — it never holds tenant data.
        'database' => env('TENANT_POOL_1_DATABASE', 'postgres'),
        'username' => env('TENANT_POOL_1_USERNAME', 'tenant_admin'),
        'password' => env('TENANT_POOL_1_PASSWORD'),
        'charset' => 'utf8',
        'prefix' => '',
        'search_path' => 'public',
        'sslmode' => 'prefer',
    ],

    'tenant_pool_2' => [
        'driver' => 'pgsql',
        'host' => env('TENANT_POOL_2_HOST', '10.0.1.11'),
        'port' => env('TENANT_POOL_2_PORT', 5432),
        'database' => env('TENANT_POOL_2_DATABASE', 'postgres'),
        'username' => env('TENANT_POOL_2_USERNAME', 'tenant_admin'),
        'password' => env('TENANT_POOL_2_PASSWORD'),
        'charset' => 'utf8',
        'prefix' => '',
        'search_path' => 'public',
        'sslmode' => 'prefer',
    ],

],
TENANT_POOL_1_HOST=10.0.1.10
TENANT_POOL_1_USERNAME=tenant_admin
TENANT_POOL_1_PASSWORD=secret

TENANT_POOL_2_HOST=10.0.1.11
TENANT_POOL_2_USERNAME=tenant_admin
TENANT_POOL_2_PASSWORD=secret

The MySQL variant:

'tenant_pool_1' => [
    'driver' => 'mysql',
    'host' => env('TENANT_POOL_1_HOST', '10.0.1.10'),
    'port' => env('TENANT_POOL_1_PORT', 3306),
    // An existing schema the admin user can connect to before any
    // tenant database exists on this server.
    'database' => env('TENANT_POOL_1_DATABASE', 'tenant_admin'),
    'username' => env('TENANT_POOL_1_USERNAME', 'tenant_admin'),
    'password' => env('TENANT_POOL_1_PASSWORD'),
    'charset' => 'utf8mb4',
    'collation' => 'utf8mb4_unicode_ci',
    'prefix' => '',
    'strict' => true,
],

Two rules for pool connections:

  • No database URLs. Template connections must use individual host/port/database fields — stancl's bootstrapper rejects URL-based configs because the URL silently overrides the per-tenant database name. Boot validation refuses URL-configured members.
  • Valid database names for your driver. Tenant database names are built from tenancy.database.prefix + tenant key + tenancy.database.suffix. Make sure the suffix suits your driver — a .sqlite suffix left over from local testing produces invalid PostgreSQL and MySQL database names.

2. Enable the pool

In config/packstub-tenancy.php:

'database_pool' => [
    'enabled' => true,
    'connections' => ['tenant_pool_1', 'tenant_pool_2'],
    'strategy' => 'least-tenants',
    'weights' => [],
],

Or fluently on the plugin — the whole database_pool block has a fluent mirror:

use Packstub\Tenancy\TenancyPlugin;

$panel->plugin(
    TenancyPlugin::make()
        ->databasePool(['tenant_pool_1', 'tenant_pool_2'])
);

With a strategy and weights:

TenancyPlugin::make()
    ->databasePool(
        ['tenant_pool_1', 'tenant_pool_2', 'tenant_pool_3'],
        strategy: 'weighted',
        weights: ['tenant_pool_3' => 2],
    );

Passing an empty array disables the pool. Fluent overrides are written back into the packstub-tenancy.database_pool config when the panel boots, so the creation hook, the tenants:pool command, and the pool widget all observe the same pool the panel was configured with.

3. Let boot validation check your work

A misconfigured pool member would otherwise fail asynchronously, inside the queued provisioning pipeline — a far worse place to learn about a typo. So the pool validates eagerly at boot and throws a descriptive RuntimeException if:

  • the pool is enabled but connections is empty;
  • a listed connection is not defined in config/database.php;
  • a member uses a name reserved by stancl tenancy (tenant, or the configured tenant_host_connection name);
  • a member is configured through a database URL;
  • a member's driver has no tenant database manager registered in tenancy.database.managers;
  • the strategy is not one of least-tenants, round-robin, weighted;
  • a weight references a connection that is not a pool member, or is not a positive number.

For the reachability and privilege side — can each server actually be reached, and can its role actually CREATE DATABASE — run tenants:pool --check (see Inspect the pool).

Placement strategies

least-tenants (default)

Picks the member currently hosting the fewest tenants. Counts come straight from the central tenants table (data->tenancy_db_connection), so the strategy needs no bookkeeping table and self-heals: deleting tenants lowers a member's count, and a freshly added server — with zero tenants — is favored for every new signup until the pool evens out. This is the strategy you want unless you have a specific reason not to.

Two simultaneous signups may read the same counts and land on the same member. That is a benign, self-correcting skew of one tenant, which is why the pool takes no lock.

round-robin

Rotates through members in configured order. The cursor is derived from the total pooled-tenant count (total % member count) rather than stored, so rotation needs no extra state — but deleting tenants shifts the cursor, so distribution can drift after churn. Use least-tenants if exact evenness after deletions matters.

weighted

Picks the member with the lowest tenants-per-weight ratio, so a server with weight 2 converges on twice the tenants of a weight-1 server. Members without an explicit weight count as weight 1. Use it when your servers are not the same size:

'database_pool' => [
    'enabled' => true,
    'connections' => ['tenant_pool_1', 'tenant_pool_2', 'tenant_pool_3'],
    'strategy' => 'weighted',
    // tenant_pool_3 is a bigger machine: it converges on 2x the tenants
    // of each weight-1 member.
    'weights' => ['tenant_pool_3' => 2],
],

Add a server to a running pool

This is the payoff: once the pool is running, scaling out is a provisioning task plus one line of config.

1. Provision the database server

Stand up PostgreSQL or MySQL on a new machine (or a new managed instance), reachable from your web servers and your queue workers. Allow their IPs in the firewall / pg_hba.conf.

2. Create the admin role

The role provisioning connects as must be able to create (and, for offboarding, drop) databases.

PostgreSQL — the CREATEDB role attribute is exactly what tenants:pool --check probes for:

CREATE ROLE tenant_admin WITH LOGIN CREATEDB PASSWORD 'choose-a-strong-password';

MySQL — a global CREATE grant is what tenants:pool --check looks for; DROP covers offboarding, and a wildcard grant covers day-to-day queries inside every tenant database (adjust tenant% to your tenancy.database.prefix):

CREATE USER 'tenant_admin'@'%' IDENTIFIED BY 'choose-a-strong-password';
GRANT CREATE, DROP ON *.* TO 'tenant_admin'@'%';
GRANT ALL PRIVILEGES ON `tenant%`.* TO 'tenant_admin'@'%';

3. Add the connection and enroll it

Add tenant_pool_3 to config/database.php (same shape as the existing members) and its env vars, then append one name to the pool:

'connections' => ['tenant_pool_1', 'tenant_pool_2', 'tenant_pool_3'],

4. Deploy — to web servers AND queue workers

Provisioning runs on the queue. A worker that does not know the tenant_pool_3 connection cannot create databases on it, so the new connection definition and env vars must reach every host: web servers and queue workers both. Deploy, rebuild the config cache if you use one, and restart workers so they load it:

php artisan config:cache
php artisan queue:restart

5. Verify and watch it fill

php artisan tenants:pool --check

With least-tenants, the next signup lands on the empty server — Next placement: tenant_pool_3 — and keeps doing so until the pool balances. Watch it happen in the DatabasePoolOverview widget or by re-running tenants:pool.

That is the whole runbook. No proxy reconfiguration, no rebalancing job, no downtime: existing tenants stay exactly where they are.

Inspect the pool

tenants:pool

Shows how tenants are distributed across the pool and flags configuration problems:

php artisan tenants:pool
+--------------------+--------+---------+--------+-------+
| Connection         | Driver | Tenants | Weight | Share |
+--------------------+--------+---------+--------+-------+
| tenant_pool_1      | pgsql  | 104     | 1      | 51%   |
| tenant_pool_2      | pgsql  | 98      | 1      | 48%   |
| tenant_pool_3      | pgsql  | 2       | 1      | 1%    |
| (template default) | —      | 37      | —      | —     |
+--------------------+--------+---------+--------+-------+

Strategy: least-tenants
Next placement: tenant_pool_3

The (template default) row appears when some tenants have no pool assignment — typically tenants created before the pool was enabled. They follow stancl's template_tenant_connection (or the central connection) and are not counted in pooled shares.

If any tenant points at a connection that no longer exists in config/database.php, the command prints an error block and exits non-zero (see Operations). The command also reports stranded tenants when the pool is disabled, so it is safe in any setup.

tenants:pool --check

Connects to every pool member and verifies it can actually create tenant databases — reachability and privileges, per driver:

php artisan tenants:pool --check
  ✓ tenant_pool_1 ......................... reachable, role can CREATE DATABASE
  ✓ tenant_pool_2 ......................... reachable, role can CREATE DATABASE
  ✗ tenant_pool_3  reachable, but the role lacks CREATEDB — provisioning will fail on this server

On PostgreSQL the probe checks the connected role for CREATEDB (or superuser); on MySQL/MariaDB it queries information_schema.USER_PRIVILEGES for a global CREATE grant (parsing SHOW GRANTS would false-positive on compound privileges like CREATE VIEW); on SQLite it verifies the tenant database directory is writable. A member that connects but lacks the privilege would otherwise fail asynchronously inside the queued pipeline — this check surfaces that before the first signup does. The exit code is non-zero on any failure, which makes the command a natural CI or cron health check.

SQL Server has no privilege probe yet: sqlsrv members are reported with a yellow ? as unknown rather than claimed healthy, and don't affect the exit code — verify the login's CREATE DATABASE (or dbcreator) permission manually.

The DatabasePoolOverview widget

A Filament stats widget with one stat per pool member: tenant count, driver and host, and a highlight on the member the next tenant will land on. Register it in your central admin panel:

use Packstub\Tenancy\Filament\Widgets\DatabasePoolOverview;

$panel->widgets([
    DatabasePoolOverview::class,
]);

The widget hides itself while the pool is disabled, so it is safe to register unconditionally.

Pin a tenant to a server

Some tenants must live on a specific server — a data-residency requirement, an enterprise contract, a dedicated machine. Create the tenant with an explicit tenancy_db_connection and the pool steps aside:

use Packstub\Tenancy\Models\Tenant;

Tenant::create([
    'name' => 'Contoso GmbH',
    'slug' => 'contoso',
    // Any connection defined in config/database.php — it does not
    // have to be a pool member.
    'tenancy_db_connection' => 'tenant_eu_dedicated',
]);

Pins are permanent by design: the assignment hook skips any tenant that already carries a connection, and no strategy, scale-out, or retry ever rebalances an assigned tenant. The same applies in reverse to database-less tenants — under the dedicated strategy, anything created with stancl's tenancy_create_database => false flag is skipped entirely, since there is no database to place.

Operations

Queue workers must share the connection config

Provisioning (CreateDatabaseMigrateDatabase → optional seeding → ready) runs on the queue, and runtime jobs that enter tenant context resolve servers the same way the web tier does. Every queue worker therefore needs the same database.connections entries and env vars as your web servers. Treat "add a pool member" as a config deploy to the whole fleet, always followed by php artisan queue:restart.

If a pool member disappears

Removing or renaming a connection in config/database.php strands every tenant whose persisted tenancy_db_connection points at it. Stranded tenants cannot boot — the bootstrapper would read a null connection config. tenants:pool flags them loudly and exits non-zero:

   ERROR  2 tenant(s) point at connections missing from config/database.php — they cannot boot:

  acme -> tenant_pool_2
  globex -> tenant_pool_2

   WARN  Restore the connection definition, or repoint the tenants at an existing pool member.

Recovery is usually trivial: restore the connection definition under its original name. The tenants' databases are untouched on the server — only the name lookup was broken. Repointing tenants at a different member is only correct after you have actually moved their data there (below).

Moving a tenant between servers

There is no automatic tenant mover — be aware of that going in. Moving a tenant is a manual dump/restore plus one attribute update:

  1. Pick a maintenance window for that tenant (writes during the copy are lost).

  2. Dump the tenant's database from its current server and restore it onto the target, for example:

    pg_dump --host 10.0.1.10 --username tenant_admin --dbname tenant_acme \
      | psql --host 10.0.1.12 --username tenant_admin --dbname tenant_acme
    

    (Create the empty database on the target first: createdb --host 10.0.1.12 --username tenant_admin tenant_acme.)

  3. Update the persisted attribute:

    $tenant = Tenant::where('slug', 'acme')->firstOrFail();
    $tenant->setInternal('db_connection', 'tenant_pool_3');
    $tenant->save();
    
  4. Verify the tenant boots and its data is present, then drop the old database on the source server yourself — nothing does it for you.

Backups

Back up every pool member — each server holds real tenant data. Back up the central database too: it holds the tenant → server map (tenancy_db_connection), and a restore is only coherent when the map and the servers agree. Include new members in the backup rotation as part of the add-a-server runbook.

Monitoring

  • Run tenants:pool --check on a schedule and alert on a non-zero exit code — it catches unreachable members, revoked privileges, and stranded tenants in one shot.
  • Watch per-server fundamentals (disk, connections, IOPS) with your regular database monitoring; the pool spreads tenants but does not observe server load.
  • Keep the DatabasePoolOverview widget on your central admin dashboard for an at-a-glance distribution view.

Testing your pool

The package ships two levels of pool tests you can run and crib from:

  • Real multi-server E2Ecomposer test:multi-server boots three independent PostgreSQL server instances (separate data directories, ports, and postmasters, via tests/bin/multi-server-pg.sh), then runs tests/E2E/MultiServerPostgresTest.php against them: tenants distributing across two real servers, runtime connections hitting the right server with fully isolated data, a third server joining mid-test and receiving the next tenant, deletion dropping the database from the right server only, and --check confirming CREATE DATABASE privileges. Without the wrapper script the suite skips itself, so a plain composer test stays dependency-free.

  • SQLite journey testtests/Feature/HorizontalScalingE2ETest.php runs the whole story on the driver every CI box has: signup → pool placement → async provisioning → isolated tenant database → add a server → recover a failed pooled tenant on its originally assigned member → offboard.

The journey test is the template for your own suite: define throwaway pool connections and the packstub-tenancy.database_pool config in setUp(), create tenants through your real signup path, then assert on $tenant->getInternal('db_connection') and app(DatabasePool::class)->distribution().

FAQ

Can I mix database drivers in one pool? Mechanically, yes — validation only requires each member's driver to have a tenant database manager registered in tenancy.database.managers, and every provisioning step follows the member's own driver. Practically, your tenant migrations then have to be portable across all drivers in the pool, so most teams standardize on one.

Do SQLite pools make sense? For testing the pool's behavior, yes — the package's own journey test runs on SQLite, and tenants:pool --check probes the tenant database directory for writability. For actual scaling, no: all SQLite tenant databases are files under one local path, so SQLite members never map to separate servers.

Can I change the strategy later? Yes, at any time. Strategy only affects future placements; existing assignments are never touched. Switching an uneven pool to least-tenants gradually rebalances it through new signups alone.

What happens to existing tenants when I enable the pool? Nothing. Assignment happens only at creation, so pre-pool tenants keep a null connection and continue to follow stancl's template_tenant_connection (or the central connection). They appear as the (template default) row in tenants:pool. If you want them on pool members, move them explicitly (see Moving a tenant between servers).

Does round-robin stay perfectly even? Not after churn. The rotation cursor is derived from the total pooled-tenant count rather than stored, so deleting tenants shifts it and distribution can drift. least-tenants is the strategy that actively converges on evenness.

Can the central database share a server with a pool member? Yes. Pool members are just connections; nothing stops tenant_pool_1 from pointing at the same physical server that hosts your central database — a common starting topology before the first scale-out. Give the pool member its own connection entry (with the admin credentials) rather than reusing the central connection, and remember the names tenant and the configured tenant_host_connection are reserved by stancl and rejected at boot.

Two tenants signed up at the same instant and landed on the same server — is that a bug? No. Placement reads live counts without locking, so simultaneous creations can briefly skew a member by one tenant. least-tenants corrects it on the very next signup.

Does provisioning retry respect the original placement? Yes. Assignment happens at creation, before provisioning can fail, so php artisan tenants:retry-provisioning {slug} re-runs the pipeline on the tenant's originally assigned server — it never rebalances. Fix the server, then retry.

Next steps

  • New to the package? Start with the installation guide.
  • Provisioning fails on a new member? tenants:pool --check first, then tenants:retry-provisioning {slug} once the server is healthy.