Packstub.

Production checklist

This guide covers everything Packstub Tenancy needs to run reliably in production: wildcard DNS and TLS for subdomain identification, queue workers for the provisioning pipeline, Octane, config caching, operating the database pool, and deploying without downtime. Work through it once before go-live, then keep the go-live checklist at the end as your pre-launch gate.

Wildcard DNS and TLS (subdomain mode)

In subdomain mode the panel is wired with full-host domain routing, and packstub-tenancy.central_domain — the TENANCY_CENTRAL_DOMAIN env variable, falling back to the host of APP_URL — is the apex tenants hang off. Every tenant lives on its own subdomain, so your DNS and TLS must cover hosts that do not exist yet. (Tenant-owned custom domains have their own DNS/TLS story — see Custom domains.)

Set both env variables to your real production domain:

APP_URL=https://example.com
TENANCY_CENTRAL_DOMAIN=example.com

DNS — point a wildcard record at the same load balancer or IP as the apex. A wildcard does not cover the apex itself, so you need both:

example.com.      A      203.0.113.10
*.example.com.    CNAME  example.com.

TLS — issue a certificate that covers example.com and *.example.com as SANs. Let's Encrypt only issues wildcard certificates via the DNS-01 challenge, so use a certbot DNS plugin for your DNS provider:

certbot certonly \
  --dns-cloudflare --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d example.com -d '*.example.com'

Alternatively, terminate TLS at a proxy or CDN that manages wildcard certificates for you (Cloudflare, or Caddy with a DNS provider module).

Web server — accept both the apex and the wildcard in one virtual host:

server {
    listen 443 ssl http2;
    server_name example.com *.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    root /var/www/app/current/public;
    # ... standard Laravel PHP-FPM location blocks
}

Path mode needs none of this — every tenant lives under example.com/{panel_path}/{route_prefix}/{slug} (e.g. example.com/admin/teams/acme) on the central host, so a single-host certificate is enough.

Session cookies across subdomains

Nothing to configure: in subdomain mode the plugin's identification middleware sets the session cookie domain per request host at runtime — .example.com (shared) on the central domain and every tenant subdomain, host-only on verified custom domains. Leave SESSION_DOMAIN unset (or null); if you set it anyway, the plugin's per-host value wins unless you disable management with manage_session_cookie => false in config/packstub-tenancy.php.

Do set the standard production cookie hardening:

SESSION_SECURE_COOKIE=true

Sessions themselves live in the central store (see Isolation model) — never enable Stancl's DatabaseSessionBootstrapper.

Queue workers

Tenant provisioning is asynchronous. When a tenant is created, the plugin queues a single pipeline job that runs CreateDatabase → MigrateDatabase → [SeedDatabase] → MarkTenantReady in-process on a worker, while the user watches the Livewire-polled provisioning page. A queue worker must be running in production — without one, every new tenant sits at provisioning forever and EnsureTenantIsReady keeps redirecting them to the provisioning screen.

Use a real queue driver (redis or database) — with QUEUE_CONNECTION=sync, provisioning runs inline inside the signup request, blocking it for the full migration run.

A minimal Supervisor program:

[program:packstub-queue]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/current/artisan queue:work --sleep=3 --tries=3 --timeout=600 --max-time=3600
autostart=true
autorestart=true
stopwaitsecs=650
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/log/packstub-queue.log

Or the equivalent Horizon supervisor:

// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['default'],
            'balance' => 'auto',
            'minProcesses' => 1,
            'maxProcesses' => 8,
            'tries' => 3,
            'timeout' => 600,
        ],
    ],
],

Sizing notes:

  • Timeout — the pipeline job runs the tenant's entire migration set (and optional seeder) in one job. Budget the timeout for a full tenants:migrate run on a cold database; 600 seconds is a sane starting point. Keep the queue connection's retry_after and Supervisor's stopwaitsecs greater than the timeout, or a slow migration gets retried while still running.
  • Retries--tries=3 is safe. The plugin sets CreateDatabase::$ignoreExisting = true, so a retry after the database was already created skips ahead to migrate/seed instead of failing on "database already exists". Migrations are idempotent by design; seed idempotency depends on your tenant seeder.
  • after_commit — you do not need 'after_commit' => true on your queue connection for provisioning. The plugin already defers the pipeline dispatch with afterCommit() on the central connection, so the job is never queued before the tenant row is committed (and never queued at all if the onboarding transaction rolls back). Enabling after_commit globally is harmless.
  • Failure handling — when the pipeline job exhausts its retries, the plugin's JobFailed listener flips the tenant to failed, and the provisioning page shows your support_url. After fixing the cause, re-run provisioning with:
php artisan tenants:retry-provisioning acme

Every worker host must define the same config/database.php connections as the web tier — the worker resolves a pooled tenant's server by connection name when it runs CreateDatabase. See zero-downtime deploys.

Octane

Packstub Tenancy is Octane-safe out of the box. When Laravel\Octane\Events\RequestReceived exists, the service provider registers the shipped Packstub\Tenancy\Listeners\RevertToCentralOnRequest listener automatically — there is nothing to configure.

What it prevents: under Octane (Swoole, FrankenPHP, RoadRunner) a worker process persists across requests. The plugin initializes Stancl tenancy from Filament's TenantSet event, but Filament fires that event only when a tenant is resolved — there is no "tenant cleared" signal. Without the listener, a worker that just served acme's request stays pinned to acme's database connection, and the next request on that worker — a central route, or another tenant — would read and write the previous tenant's database.

The listener ends tenancy at the start of every request, guaranteeing a clean central baseline; genuine tenant routes then re-initialize through the TenantSet bridge as usual. On traditional PHP-FPM (one request per process) the Octane event class does not exist and the listener is never wired.

Config caching

php artisan config:cache is fully supported. The database pool reads packstub-tenancy.database_pool.* from config at runtime — enabled flag, connections, strategy, and weights all come from the (cached) config repository, so caching changes nothing about pool behavior. Rebuild the cache on every deploy, on web and worker hosts alike.

One caveat: config/packstub-tenancy.php accepts closures in menu.items, and closures cannot be serialized by config:cache. If you cache config, register closure-based menu items through the fluent plugin API in your panel provider instead of the config file.

For production, prefer configuring the pool in config/packstub-tenancy.php (or .env-driven values) over the fluent ->databasePool() call — the config file is read identically by web requests, queue workers, and Artisan commands, so every process type sees the same pool.

Running the database pool in production

Read horizontal scaling first for how placement works. The operational rules below assume a pool of connections in config/database.php, each pointing at a different database server.

Least-privilege connection roles

Each pool member is a privileged connection: provisioning runs CREATE DATABASE (and tenant deletion runs DROP DATABASE) over it, and Stancl's runtime bootstrapper uses it as the template for tenant connections. Keep it privileged enough — and nothing more:

  • PostgreSQL — create a dedicated role per server with the CREATEDB attribute. Do not use a superuser:

    CREATE ROLE tenant_provisioner LOGIN PASSWORD '...' CREATEDB;
    

    Databases the role creates are owned by it, so it automatically has full rights on tenant databases — no extra grants needed.

  • MySQL/MariaDB — create a dedicated user with global CREATE and DROP (required to create and delete tenant databases) plus the DML/DDL privileges tenants need. Do not use root.

  • Never reuse your central application's database role for pool members, and do not give the central role CREATEDB/global CREATE. The central connection serves requests; pool connections provision. Keeping them separate limits the blast radius of a leaked app credential.

  • Template connections must use discrete host/port/database fields — the plugin rejects URL-based pool connections at boot — and the configured database must be an existing maintenance database on that server.

Verify reachability and privileges on every member before launch:

php artisan tenants:pool --check

The --check probe confirms each PostgreSQL role has CREATEDB and each MySQL user has a global CREATE grant — the exact privileges provisioning will need, surfaced before the first signup instead of asynchronously inside a failed queue job.

Backups and point-in-time recovery

One database per tenant changes the backup unit: you back up servers, and you restore tenants.

  • Run per-server physical backups with continuous archiving — pg_basebackup + WAL archiving on PostgreSQL, XtraBackup + binlogs on MySQL — so every tenant database on a server gets point-in-time recovery from one pipeline. Adding a pool member means adding it to the backup pipeline before it takes tenants.
  • Back up the central database with the same rigor. The central tenants table stores each tenant's tenancy_db_connection — the mapping of tenants to servers. Losing it strands every tenant database with no record of what belongs where.
  • Rehearse restoring a single tenant: restore the server backup to a scratch instance, dump the one tenant database, and load it back onto the pool member. Test this before you need it.

Monitoring the pool

tenants:pool exits non-zero when something is wrong: stranded tenants (a persisted tenancy_db_connection that no longer exists in config/database.php), and — with --check — unreachable members or members missing the create-database privilege. Schedule it and alert on failure:

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('tenants:pool --check')
    ->everyFifteenMinutes()
    ->onOneServer()
    ->emailOutputOnFailure('ops@example.com');

emailOutputOnFailure() (or pingOnFailure() toward your monitoring endpoint) fires whenever the command exits with code 1, and the captured output names the exact stranded tenants or failing members. Run it without --check more frequently if you want a cheap distribution snapshot without connecting to every server.

Scaling the queue tier

Provisioning load is spiky — it tracks signups, not traffic. To absorb onboarding bursts:

  • Increase Supervisor's numprocs or Horizon's maxProcesses; each provisioning pipeline is a single independent job, so workers scale horizontally with no coordination.
  • Add worker hosts freely, with one hard rule: every worker host must ship the same config/database.php connections as the web tier. A worker that picks up a CreateDatabase job for a tenant placed on tenant_pool_2 resolves that server by connection name from its own local config.
  • With Horizon, balance => 'auto' shifts processes toward the provisioning backlog automatically.

Zero-downtime deploys

Use atomic releases (build in a new directory, switch a current symlink) and treat the web tier and the queue tier as one deployment unit:

php artisan config:cache   # in the new release, on every host
php artisan queue:restart  # workers finish their current job, then reload the new code + config

Queue workers are long-running processes — they keep the old code and config until restarted. This matters doubly for the database pool: if the web tier starts placing tenants on a newly added tenant_pool_3 while a worker still runs config that lacks that connection, the worker's CreateDatabase job fails.

So when you add a pool member, deploy in two steps:

  1. Add the connection to config/database.php, deploy to all hosts (web and workers), config:cache, queue:restart.
  2. Append the connection name to database_pool.connections and deploy again.

Both steps are plain config deploys with no downtime; the least-tenants strategy starts favoring the new server as soon as step 2 lands. Removing a member is the reverse — and riskier: see the stranded-tenants warning in horizontal scaling.

Go-live checklist

  • APP_URL and TENANCY_CENTRAL_DOMAIN set to the production domain
  • Subdomain mode: wildcard DNS record and apex record in place
  • Subdomain mode: TLS certificate covers example.com and *.example.com
  • SESSION_SECURE_COOKIE=true (SESSION_DOMAIN stays unset — the plugin manages it per host)
  • Custom domains enabled: per-domain TLS issuance in place (platform custom domains, Caddy on-demand TLS, or Cloudflare for SaaS — see Custom domains)
  • QUEUE_CONNECTION is redis or database — not sync
  • Supervisor/Horizon workers running, timeout sized for a full tenant migration run, retry_after > timeout
  • support_url set in config/packstub-tenancy.php so failed provisioning shows your support channel
  • Pool members use dedicated roles: CREATEDB (PostgreSQL) / global CREATE + DROP (MySQL), never the central app role
  • php artisan tenants:pool --check passes on every host type (web, worker)
  • Per-server backups with point-in-time recovery, central database included; single-tenant restore rehearsed
  • tenants:pool --check scheduled with failure alerting
  • Deploy pipeline runs config:cache and queue:restart on every host, every release
  • Octane only: confirm you are on the plugin's shipped Octane listener (automatic — nothing to configure)