Custom domains
Let each tenant serve their panel from a domain they own — app.acme-corp.com instead of acme.myapp.test — with DNS ownership verification, full-host routing, and single-origin authentication handled by the plugin. Subdomain identification mode only.
Enable the feature
TenancyPlugin::make()->customDomains()
or in config/packstub-tenancy.php:
'custom_domains' => [
'enabled' => true,
],
Enabling it turns on three things:
- a Domains page in the tenant panel (visible to tenant owners) to add, verify, and remove domains, plus the
tenants:domainsArtisan command for operators; - routing: verified custom domains resolve the tenant exactly like subdomains do;
- the auth handoff endpoints that sign users in on custom domains (see below).
How a tenant attaches a domain
-
Add the domain on the Domains page (or
php artisan tenants:domains acme --add=app.acme-corp.com). It starts unverified — unverified domains never identify a tenant, so someone pointing a domain at your platform gains nothing until they prove ownership. -
Point DNS at your deployment — typically a CNAME from
app.acme-corp.comto the tenant's default subdomain (acme.myapp.test), or an A/ALIAS record per your host's instructions. -
Publish the verification TXT record shown on the page:
_packstub-verify.app.acme-corp.com TXT packstub-verify=<token>A CNAME pointing the domain at the tenant's default subdomain (or the central domain) also counts as proof — it demonstrates control and routing at once.
-
Verify. The plugin checks DNS and stamps
verified_at. From that moment the domain resolves the tenant.
Operators can bypass DNS with php artisan tenants:domains acme --force-verify=app.acme-corp.com (useful in local/dev environments where no real DNS exists — add a hosts-file or Herd alias instead).
How sign-in works (the handoff)
Browsers no longer share cookies across registrable domains, so a session on myapp.test is invisible on app.acme-corp.com. The plugin uses the post-third-party-cookie standard pattern (Clerk satellite domains, Shopify, Auth0): login happens on exactly one origin — your central domain — and identity crosses to custom domains via a single-use, short-lived code.
- An unauthenticated visit to
app.acme-corp.com/adminredirects to the central authorize endpoint, carrying only the target host (validated against verified domains — never a free-form URL) and a path. - If a central session exists, nothing is shown (silent). Otherwise the central login form appears — and the flow resumes after login.
- The server mints a 256-bit single-use code (only its hash is stored, TTL 60 seconds) and redirects to
https://app.acme-corp.com/auth/handoff?code=…. - That landing page — no referrer leakage, non-cacheable, zero third-party assets — immediately POSTs the code same-origin. The exchange atomically consumes the code (replays fail), re-validates the tenant↔host mapping at redemption time, regenerates the session, and signs the user in behind a host-only cookie scoped to the custom domain.
The default is fully silent. To show a "Continue as …" confirmation on the custom domain instead:
TenancyPlugin::make()->customDomains()->handoffInterstitial()
Failures (expired code, replay, tampering) are deliberately generic — the visitor is returned to the central login; details are logged server-side under a correlation id, and rate limiting is keyed per host and per code (never per client IP, which CDN edges don't reliably preserve).
Logout propagates everywhere
Every custom-domain session references a central session grant. Logging out on any host — central, subdomain, or custom domain — revokes all grants minted from that central session and destroys the central session itself. Custom domains notice on their next request (each request re-validates the grant — a cheap central query); subdomains notice immediately because their shared session is gone. Expect propagation within one request per host.
Things to know
- HTTPS/TLS is deployment-level. The plugin verifies ownership and routes requests; issuing certificates for tenant domains is your platform's job — e.g. Laravel Cloud/Vapor custom domains, Caddy on-demand TLS, Cloudflare for SaaS, or a wildcard + per-domain ACME setup on your own proxy. Serve custom domains over HTTPS only; the session cookie is issued
Securewhen the request is. - Passkeys/WebAuthn stay on the central domain. The credentials are origin-bound; a passkey registered on
myapp.testcan never sign in directly onapp.acme-corp.com— which is fine, because login only ever happens centrally and the handoff carries the session across. - Remember-me is per-host. The central session's lifetime is what makes re-handoffs silent — protect it (sensible session lifetime, rotation).
- Slug renames don't touch custom domains. Renaming a tenant's slug moves its default subdomain; explicitly added domains are never modified.
- Sanctum guards are unsupported for the handoff (it requires a stateful guard); the guard in use is captured when the code is minted and re-checked at redemption.
- URL generation follows the host. While a user browses
app.acme-corp.com, every generated panel link stays on it; links generated anywhere else (central panel, email, console) use the tenant's default subdomain.
Configuration reference
'custom_domains' => [
'enabled' => false,
// Show "Continue as …" instead of signing in silently.
'interstitial' => false,
// Seconds a handoff code stays redeemable. Hard-capped at 120.
'handoff_ttl' => 60,
// Path of the landing/exchange endpoint on tenant hosts.
'handoff_path' => 'auth/handoff',
// DNS TXT record name prefix used for ownership verification.
'verification_prefix' => '_packstub-verify',
],
Fluent equivalents: ->customDomains(bool) and ->handoffInterstitial(bool).
The tenants:domains command
php artisan tenants:domains # list every domain
php artisan tenants:domains acme # one tenant, with pending TXT records
php artisan tenants:domains acme --add=app.acme-corp.com # attach (unverified)
php artisan tenants:domains acme --verify=app.acme-corp.com # run DNS verification
php artisan tenants:domains acme --force-verify=app.acme-corp.com # mark verified, no DNS check
php artisan tenants:domains acme --remove=app.acme-corp.com # detach
The default {slug}.central-domain subdomain can never be removed, and domains inside the central namespace can never be added manually — they're managed by the slug.