# 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](https://github.com/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:

```bash
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:

```bash
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:

```gitignore
auth.json
```

### CI environments

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

```bash
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

```bash
composer require packstub/filament-tenancy
```

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

## Run the installer

```bash
php artisan packstub-tenancy:install
```

The installer is interactive. It:

1. **Asks how tenants should be identified** — `subdomain` (`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`](https://packstub.dev/docs/filament-tenancy/configuration#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](#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:

```php
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`):

```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](https://packstub.dev/docs/filament-tenancy/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](https://packstub.dev/docs/filament-tenancy/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):

```bash
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
<?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](#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](https://packstub.dev/docs/filament-tenancy/horizontal-scaling).

## Verify the install

Start a queue worker — provisioning runs through the queue:

```bash
php artisan queue:work
```

Create a tenant in Tinker:

```bash
php artisan tinker
```

```php
$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:

```bash
php artisan tenants:retry-provisioning acme
```

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

```bash
php artisan tenants:pool --check
```

The command shows how tenants are distributed across your database servers and flags configuration problems — see [Horizontal scaling](https://packstub.dev/docs/filament-tenancy/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](mailto:support@packstub.dev).
