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:migraterun on a cold database;600seconds is a sane starting point. Keep the queue connection'sretry_afterand Supervisor'sstopwaitsecsgreater than the timeout, or a slow migration gets retried while still running. - Retries —
--tries=3is safe. The plugin setsCreateDatabase::$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' => trueon your queue connection for provisioning. The plugin already defers the pipeline dispatch withafterCommit()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). Enablingafter_commitglobally is harmless.- Failure handling — when the pipeline job exhausts its retries, the plugin's
JobFailedlistener flips the tenant tofailed, and the provisioning page shows yoursupport_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
CREATEDBattribute. 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
CREATEandDROP(required to create and delete tenant databases) plus the DML/DDL privileges tenants need. Do not useroot. -
Never reuse your central application's database role for pool members, and do not give the central role
CREATEDB/globalCREATE. 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/databasefields — the plugin rejects URL-based pool connections at boot — and the configureddatabasemust 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
tenantstable stores each tenant'stenancy_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
numprocsor Horizon'smaxProcesses; 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.phpconnections as the web tier. A worker that picks up aCreateDatabasejob for a tenant placed ontenant_pool_2resolves 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:
- Add the connection to
config/database.php, deploy to all hosts (web and workers),config:cache,queue:restart. - Append the connection name to
database_pool.connectionsand 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_URLandTENANCY_CENTRAL_DOMAINset to the production domain - Subdomain mode: wildcard DNS record and apex record in place
- Subdomain mode: TLS certificate covers
example.comand*.example.com -
SESSION_SECURE_COOKIE=true(SESSION_DOMAINstays 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_CONNECTIONisredisordatabase— notsync - Supervisor/Horizon workers running,
timeoutsized for a full tenant migration run,retry_after> timeout -
support_urlset inconfig/packstub-tenancy.phpso failed provisioning shows your support channel - Pool members use dedicated roles:
CREATEDB(PostgreSQL) / globalCREATE+DROP(MySQL), never the central app role -
php artisan tenants:pool --checkpasses on every host type (web, worker) - Per-server backups with point-in-time recovery, central database included; single-tenant restore rehearsed
-
tenants:pool --checkscheduled with failure alerting - Deploy pipeline runs
config:cacheandqueue:restarton every host, every release - Octane only: confirm you are on the plugin's shipped Octane listener (automatic — nothing to configure)