Packstub.

Installation

This guide walks you through installing Packstub Tenancy in a Laravel application — from adding the private Composer registry with your access token, through the interactive installer, to a fully wired Filament panel with multi-database tenancy. By the end you will have created your first tenant and watched it provision through the queue.

Requirements

Requirement Version
PHP ^8.4
Laravel ^13.0
Filament ^5.0
stancl/tenancy v4 (installed automatically from the Packstub registry — see below)

The tenant database managers support SQLite, MySQL, MariaDB, PostgreSQL, and SQL Server. The database user on each tenant database server needs permission to create and drop databases: CREATE / DROP DATABASE privileges on MySQL and MariaDB, or the CREATEDB role attribute on PostgreSQL.

A queue worker is required in production. Tenant provisioning (database creation, migration, seeding) runs through your queue — without a running worker, new tenants stay on the provisioning screen forever.

stancl/tenancy v4 stability

Packstub Tenancy is built on stancl/tenancy v4. Upstream develops v4 on its master branch and hasn't tagged a release yet, so Packagist alone can't satisfy a default (stable) Composer setup. You don't need to work around that: the Packstub registry serves a vetted v4 snapshot as a regular stable release — a date-stamped version of the form 4.0.0.<YYYYMMDD>, where the date is the mirrored upstream commit's — and the plugin requires ^4.0, so a plain composer require resolves everything with Composer's default stability settings. No minimum-stability changes, no commit pins, nothing extra in your composer.json.

Every served snapshot is auditable: the exact tree behind each version is tagged mirror/<version> in the public provenance repository packstub/stancl-tenancy-mirror. Packstub Tenancy is developed and tested against exactly the snapshot it ships with, and each release's changelog states the snapshot it was vetted against. Because Composer treats custom repositories as canonical for the packages they serve, your installs resolve stancl/tenancy exclusively from the registry — an upstream push can never change what your deployments install; new snapshots only arrive when you run composer update after we publish one.

Once stancl/tenancy tags a stable 4.0 release, the registry will step aside and stancl/tenancy will resolve from Packagist again — a routine composer update, no changes needed on your side.

Add the private Composer registry

Packstub Tenancy is distributed through a private Composer registry. Add it to your project:

composer config repositories.packstub-filament-tenancy composer https://packstub.dev/composer/filament-tenancy

Authenticate with your access token

Your purchase email links to your Packstub dashboard; its Install Guide page shows every command on this page ready to copy, with your access token filled in. The token username starts with pkg_, and the token secret is revealed once when the token is created:

composer config --auth http-basic.packstub.dev pkg_xxxxxxxxxxxxxxxx your-token-secret

This writes an auth.json file in your project root. Never commit auth.json — recent Laravel skeletons exclude it in .gitignore by default; if yours does not, add it:

auth.json

CI environments

In CI, provide the credentials through the COMPOSER_AUTH environment variable instead of an auth.json file:

COMPOSER_AUTH='{"http-basic":{"packstub.dev":{"username":"pkg_xxxxxxxxxxxxxxxx","password":"your-token-secret"}}}'

Store the value as a secret in your CI provider (e.g. a GitHub Actions repository secret) and expose it to the composer install step. You can create a separate token per environment from the dashboard, so CI credentials can be rotated or revoked without touching your local setup.

Install the package

composer require packstub/filament-tenancy

Composer resolves the package from the private registry and pulls in stancl/tenancy v4 automatically.

Run the installer

php artisan packstub-tenancy:install

The installer is interactive. It:

  1. Asks how tenants should be identifiedsubdomain (acme.example.com, recommended) or path (example.com/admin/teams/acme). You can change this later in config/packstub-tenancy.php.
  2. Publishes the config file to config/packstub-tenancy.php.
  3. Asks to run the package migrations — the tenants, tenant_user (owner/member pivot), domains, and tenant_resources tables, plus the tenant status and avatar_url columns. They run straight from the package against your central database — nothing is copied into database/migrations. (Want to own the schema? See the run_migrations config key.)
  4. Writes your identification choice into the published config — if you picked path, the installer rewrites 'identification' => 'subdomain' to 'path' in the published file, so the config matches your answer without hand-editing.
  5. Publishes a corrected config/tenancy.php (stancl/tenancy's config) with the plugin's defaults applied: the tenant model pointed at the package's, DatabaseSessionBootstrapper disabled (stancl's own default enables it — sessions are central by design, see Isolation model), and, in path mode, the PathTenantResolver's tenant_model_column set to 'slug'. An existing config/tenancy.php is never overwritten.
  6. Prints mode-specific next steps — the wiring below, tailored to the identification mode you chose.

For scripted installs, pass --no-interaction: the prompt is skipped and the identification mode stays at the shipped default, subdomain.

Note: Packstub Tenancy ships its own tenants and domains migrations. Do not also publish stancl/tenancy's migrations (--tag=migrations) — the two sets create the same tables and will collide.

Set up your User model

Add the HasPackstubTenants trait and implement Filament's HasTenants and HasDefaultTenant contracts. Also add Stancl's CentralConnection trait so user identity always lives in the central database, even when a request is served from a tenant domain:

use Filament\Models\Contracts\{HasDefaultTenant, HasTenants};
use Illuminate\Foundation\Auth\User as Authenticatable;
use Packstub\Tenancy\Concerns\HasPackstubTenants;
use Stancl\Tenancy\Database\Concerns\CentralConnection;

class User extends Authenticatable implements HasDefaultTenant, HasTenants
{
    use CentralConnection;        // identity always lives in the central DB
    use HasPackstubTenants;      // implements canAccessTenant + getTenants + getDefaultTenant
}

HasPackstubTenants implements all three contract methods. The tenant list and switcher include only ready tenants; if a user has no ready tenant yet, their newest tenant is still returned as the default so the EnsureTenantIsReady middleware can route them to its provisioning status screen — users are never dropped into a panel backed by a half-migrated tenant database.

Register the plugin in your panel

Register TenancyPlugin in your panel provider (typically app/Providers/Filament/AdminPanelProvider.php):

use Packstub\Tenancy\TenancyPlugin;

public function panel(Panel $panel): Panel
{
    return $panel
        ->id('admin')
        ->path('admin')
        ->plugin(TenancyPlugin::make());
}

The plugin reads config/packstub-tenancy.php and wires Filament's tenancy for you: the tenant model, the onboarding page, the tenant menu and switcher, the EnsureTenantIsReady middleware, and — depending on your identification mode — full-host domain routing (->tenantDomain('{tenant:route_host}') with host→tenant resolution via verified domain rows) or ->tenantRoutePrefix(). Most config keys also have a fluent plugin method if you prefer configuring in code (a few — tenant_model, central_domain, seeder, support_url — are config-file only); the explicit override always wins over the config value.

Identification middleware: none to wire

You add no stancl identification middleware to your panel, in either mode.

  • Subdomain mode: the plugin registers its own central-domain-aware identification middleware in the global stack. Per request it skips the central domain, matches tenant hosts (subdomains and verified custom domains) against the domains table, sets the session cookie domain for that host (shared .central-domain cookie on subdomains, host-only on custom domains — disable with manage_session_cookie => false), and keeps auth pages on the central origin. Filament's tenant resolution and the plugin's TenantSet bridge then initialize stancl tenancy after auth.

    You do need wildcard DNS (*.example.com) pointing at your app. Locally, tools like Laravel Herd and Valet resolve *.myapp.test automatically. The central domain — where login and tenant onboarding live — is parsed from APP_URL; override with TENANCY_CENTRAL_DOMAIN if they differ.

    Adding InitializeTenancyByDomain / PreventAccessFromUnwantedDomains to the panel middleware yourself — the wiring some stancl guides describe — breaks the central domain (TenantCouldNotBeIdentifiedOnDomainException on login) because stancl's skip logic only applies in the global stack. Don't.

  • Path mode: nothing to add either; the TenantSet bridge does the work. Adding InitializeTenancyByPath would throw on the central /login route. The installer already set the PathTenantResolver's tenant_model_column to 'slug' in the published config/tenancy.php to match Filament's route binding — if you manage your own tenancy.php, set it there yourself (covered in the reference config below).

Isolation model

What lives where — this is deliberate, not configurable:

  • Per-tenant database: every table you put in database/migrations/tenant/ — the tenant's own data. Isolation is at the connection level; no tenant_id columns, no global scopes.
  • Central database: tenants, domains, the tenant_user pivot — and users, sessions, and auth. Login happens on exactly one origin (the central domain); every host gets its own first-party session cookie, and identity reaches custom domains through a single-use, short-TTL code handoff (Custom domains). Keeping sessions central is what makes the tenant switcher, cross-subdomain login, and "logged out everywhere" revocation work — and it's the post-third-party-cookie industry consensus (Clerk, Shopify, Auth0 all do the equivalent).

Consequently, DatabaseSessionBootstrapper stays out of tenancy.bootstrappers in every mode, and users/sessions tables don't belong in tenant migrations. Heads-up: stancl/tenancy's own published config ships with that bootstrapper enabled — the installer publishes config/tenancy.php with it disabled for you, and the plugin refuses to boot if it gets re-enabled (a clear RuntimeException at boot, instead of a puzzling no such table: sessions on tenant pages). (If you need tenant-local copies of user records for foreign keys, that's what resource syncing is for.)

Configuring stancl/tenancy

The installer already published config/tenancy.php (stancl/tenancy's config) with the plugin's defaults applied — the tenant model points at the package's, and DatabaseSessionBootstrapper is disabled. If you'd rather start from stancl's raw file (config only — not its migrations):

php artisan vendor:publish --provider="Stancl\Tenancy\TenancyServiceProvider" --tag=config

…then make those two edits by hand; the plugin refuses to boot while DatabaseSessionBootstrapper is enabled, so a missed edit surfaces immediately with instructions rather than as a broken tenant page.

The annotated reference below shows every key Packstub Tenancy cares about, trimmed to the relevant parts of config/tenancy.php. Keys not shown can keep their published defaults.

<?php

declare(strict_types=1);

use Stancl\Tenancy\Bootstrappers;
use Stancl\Tenancy\Middleware;
use Stancl\Tenancy\Resolvers;

return [
    'models' => [
        // Must match packstub-tenancy.tenant_model — the shipped model, or
        // your own model using the IsPackstubTenant trait.
        'tenant' => \Packstub\Tenancy\Models\Tenant::class,

        // Leave the published default (stancl's Domain) — the plugin
        // upgrades it to \Packstub\Tenancy\Models\Domain automatically, which
        // adds ownership verification (verified_at / verification_token) for
        // custom domains. Only set this if you bind your own subclass.
        'domain' => \Stancl\Tenancy\Database\Models\Domain::class,

        // Leave the published default. The shipped tenants migration uses a
        // STRING primary key ($table->string('id')->primary()) — Stancl's
        // convention — so the default UUIDGenerator works out of the box. Any
        // custom generator returning unique strings also works. Do NOT set
        // this to null unless you publish the migrations and switch the
        // column to an autoincrement key yourself (see the README's
        // "Need a custom schema?" section).
        'id_generator' => \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDGenerator::class,
    ],

    'identification' => [
        // The domains serving your CENTRAL app (login, registration,
        // onboarding). In subdomain mode, any host NOT listed here is treated
        // as a tenant domain. The default derives the host from APP_URL.
        'central_domains' => [
            str(env('APP_URL'))->after('://')->before('/')->before(':')->toString(),
        ],

        'default_middleware' => Middleware\InitializeTenancyByDomain::class,

        'resolvers' => [
            Resolvers\PathTenantResolver::class => [
                'tenant_parameter_name' => 'tenant',

                // PATH MODE ONLY — REQUIRED. Filament binds the {tenant} route
                // parameter by slug; Stancl resolves by primary key by default.
                // Without this, path identification looks up the wrong column.
                'tenant_model_column' => 'slug',
            ],
        ],
    ],

    /*
     * Bootstrappers run when tenancy initializes and make Laravel features
     * tenant-aware.
     */
    'bootstrappers' => [
        // REQUIRED. Rewires the default database connection to the tenant's
        // database — this is what makes multi-database tenancy work.
        Bootstrappers\DatabaseTenancyBootstrapper::class,

        // Recommended: prefixes cache keys per tenant.
        Bootstrappers\CacheTenancyBootstrapper::class,

        // Recommended: per-tenant storage directories.
        Bootstrappers\FilesystemTenancyBootstrapper::class,

        // Recommended: re-initializes tenancy inside queued jobs.
        Bootstrappers\QueueTenancyBootstrapper::class,

        // DatabaseSessionBootstrapper stays disabled. Sessions and auth are
        // central by design in every mode — see "Isolation model" above. A
        // tenant-DB session would break login, the tenant switcher, and
        // logout revocation. stancl's raw config ships it ENABLED; the
        // installer publishes it commented out, and the plugin refuses to
        // boot if it comes back.
    ],

    'database' => [
        // REQUIRED — Packstub Tenancy refuses to boot if this is null or
        // empty (it throws a RuntimeException with instructions). The plugin
        // pins all central identity — the Tenant model, onboarding and profile
        // writes, resource-syncing masters — to this connection; without it,
        // those writes would silently land in the ACTIVE (tenant) database.
        // The default resolves from DB_CONNECTION, which is fine for most apps.
        'central_connection' => env('DB_CONNECTION', 'central'),

        // Optional "template" for the dynamically created tenant connection.
        // Note: never name a connection "tenant" — that name is reserved.
        // If you enable the database pool, pool members act as per-server
        // templates instead — see ./horizontal-scaling.md. Template
        // connections must be defined as arrays, not DB URLs.
        'template_tenant_connection' => null,

        // Tenant database names: prefix + tenant key + suffix.
        // e.g. 'tenant' . 42 . '' → "tenant42"
        'prefix' => 'tenant',
        'suffix' => '',

        // Which manager creates/drops tenant databases, per driver.
        'managers' => [
            'sqlite' => \Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager::class,
            'mysql' => \Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager::class,
            'mariadb' => \Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager::class,
            'pgsql' => \Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLDatabaseManager::class,
            'sqlsrv' => \Stancl\Tenancy\Database\TenantDatabaseManagers\MicrosoftSQLDatabaseManager::class,
        ],
    ],

    /*
     * Parameters for tenant migrations. The provisioning pipeline's
     * MigrateDatabase job runs migrations from database/migrations/tenant —
     * create that directory and put your tenant-database schema there.
     * Central tables (users, tenants, domains) stay in database/migrations.
     */
    'migration_parameters' => [
        '--force' => true,
        '--path' => [database_path('migrations/tenant')],
        '--realpath' => true,
    ],

    /*
     * Leave this alone. Tenant seeding is opt-in via the
     * packstub-tenancy.seeder config key: when you set a tenant-safe seeder
     * class there, the plugin points Stancl at it (and forces it, so queued
     * workers never prompt). When left null, provisioning skips seeding
     * entirely instead of running your central DatabaseSeeder against a
     * fresh tenant database.
     */
    'seeder_parameters' => [],
];

Two rules worth restating — the plugin validates both at boot and throws a clear RuntimeException rather than letting them break the app quietly:

  • database.central_connection must be set. A missing central connection would route central writes into whichever tenant database is active.
  • DatabaseSessionBootstrapper must not be enabled — in any mode. Sessions and auth live on the central connection by design (see Isolation model). stancl's raw published config enables it, so this guard is what catches a raw re-publish.

If you plan to scale tenant databases across multiple servers, configure the database pool after finishing this guide — see Horizontal scaling.

Verify the install

Start a queue worker — provisioning runs through the queue:

php artisan queue:work

Create a tenant in Tinker:

php artisan tinker
$owner = App\Models\User::first();

$tenant = app(Packstub\Tenancy\Services\TenantOnboarder::class)
    ->create(name: 'Acme Inc.', slug: 'acme', owner: $owner);

$tenant->refresh()->status;
// "provisioning" immediately; "ready" once the worker finishes the pipeline

The worker runs CreateDatabase → MigrateDatabase → MarkTenantReady (plus SeedDatabase if you configured a tenant seeder). Once status is ready:

  • Subdomain mode — visit http://acme.your-central-domain/admin and log in as the owner.
  • Path mode — visit http://your-central-domain/admin and log in; Filament redirects you into /admin/teams/acme.

If the status flips to failed instead, check the worker output and failed_jobs, fix the cause (a broken tenant migration is the usual suspect), then re-run the pipeline:

php artisan tenants:retry-provisioning acme

If you enabled the database pool, confirm the assignment and pool health:

php artisan tenants:pool --check

The command shows how tenants are distributed across your database servers and flags configuration problems — see Horizontal scaling.

One final reminder: in production, a queue worker (Supervisor, Horizon, or your platform's equivalent) must be running at all times, and every worker host needs the same database.connections entries defined as your web servers — provisioning jobs resolve tenant database servers by connection name.

Need help? Email support@packstub.dev.