I maintain (construction company) Every click provisions a brand-new disposable tenant and drops you on its dashboard. Three hours later, a cron job deletes it. This post is about how that works. A shared demo account is the easy option, and it's bad. Every visitor sees the previous visitor's test data. You end up writing a reset cron, and then someone gets reset mid-click. A read-only mode protects the data by removing the product. "Create an invoice, register a payment, watch it reconcile" is the whole pitch — a demo where the write buttons are disabled demos nothing. Disposable tenants solve both, if you already have multi-tenancy. That's the real precondition. My app already isolated everything by organization (each row carries an Nothing in that list is demo-specific infrastructure. Creating an org, creating its admin, issuing a refresh token — those are the same production use cases a real signup would call. The demo handler just calls them in a row and returns a redirect. The The admin password is random and never shown to anyone. There is deliberately no way to log into a demo org through the front door. Demo credibility lives and dies on seed data, and seed data has a classic failure mode: hardcoded dates. Six months after you write "invoice dated 2026-01-15", your demo shows an ancient dashboard. All seeded dates are relative to today: a few invoices issued this month, one overdue, a couple paid last week. Whenever you click the link, the dashboard looks like a business that is alive right now. The seeder also plants one deliberately messy case per template — a bank transfer whose payer name doesn't quite match the client name, waiting to be reconciled. Anyone who has done accounts receivable recognizes that pain instantly. One realistic wart sells the product better than ten clean records. The tricky part: after creating the org, the visitor must land inside it, authenticated, without seeing a login screen. The handler issues the exact same refresh + CSRF cookies a real login would — but path-scoped to the new tenant's slug: (That's a real response from the live demo.) The SPA loads, does its normal silent-refresh against One trade-off accepted: the session is one-shot. Reload and you're back at the login screen. Fixing it would mean modifying the auth core for the demo's sake, so instead the demo leans into it — hitting the URL again gives you a fresh tenant, which is the "reset demo" button. With disposable tenants, reset isn't a feature you build; it comes free. A cron job runs hourly: The second rule matters. TTL alone is fragile — if the cron stalls or someone scripts a thousand clicks, you want a hard cap on how much garbage can exist. With the cap, a bot hammering the demo link just churns its own tenants. Child rows (invoices, line items, payments…) are bulk-deleted by The demo feature took a day. The reason it took a day is that years of boring discipline — tenant isolation on every row, org creation as a proper use case, token issuance behind an interface — had already paid for it. The glue layer got to be thin because the boundaries underneath were real. If your app is multi-tenant, "click a link, get a throwaway tenant" is probably cheaper to build than the reset cron you were about to write for a shared demo account. Code:
(creative agency, withholding tax)
Why not the usual demo setups
organization_id, every use case is org-scoped), so a demo mode became a thin layer of glue: about 900 lines including the seeder and the cleanup script, with zero changes to the auth core.
The flow
GET /demo/{template}
→ create org with a random slug (normal CreateOrganization use case)
→ seed industry data into it (DemoDataSeeder)
→ issue session cookies, path-scoped (normal RefreshTokenIssuer)
→ 302 to /{slug}/dashboard
The pieces worth stealing
1. Gate it behind an env var, and 404
if ($this->env('DEMO_MODE', '0') !== '1') {
return $this->problemDetails->create($request, 'not-found',
'Not Found', 404, 'Demo mode is not enabled on this instance.');
}
/demo/* is a public, unauthenticated route, so on any instance that isn't the demo instance it shouldn't just be forbidden — it should not exist. A 404 (not a 403) means production installs don't even advertise that a demo feature is in the codebase.
2. Random slug, and the prefix is the contract
$slug = 'demo-' . bin2hex(random_bytes(4)); // demo-3f9a1c02
demo- prefix does double duty: it namespaces the tenant and marks it as garbage-collectable. The cleanup script selects targets by this prefix alone, so it is structurally incapable of touching a real organization.
3. Seed data that never goes stale
4. Session handoff without touching the auth core
HTTP/2 302
set-cookie: ni_refresh=...; Path=/demo-5b067975/auth; Secure; HttpOnly; SameSite=Strict
set-cookie: ni_csrf=...; Path=/demo-5b067975/; Secure; SameSite=Strict
location: /demo-5b067975/dashboard
/{slug}/auth/refresh, gets an access token, and renders. Token rotation, cookie contents, auth middleware: all untouched.
5. Cleanup that is allowed to be crude
demo- orgs older than DEMO_TTL_HOURS (default 3)
also delete the oldest ones beyond DEMO_MAX_ORGS (default 200)
organization_id, exceptions swallowed. Normally that's sloppy; here it's fine, and that's the point — when data is disposable by construction, the cleanup code gets to be simple.
Guardrails, summarized
Concern
Answer
Runs on production installs?
No — DEMO_MODE=1 gate, 404 otherwise
Touching real tenants?
Impossible by construction ( demo- prefix)
Login route into demo orgs?
None — random password, never disclosed
Data lifetime
3h TTL, hourly sweep
Abuse / DoS
Hard cap on org count, oldest evicted
"Reset the demo"
Not built — click the link again
The takeaway
src/Demo/ and tools/sweep-demo.php in NeNe Invoice (MIT).
Click a link, get a throwaway tenant: a zero-signup demo for a self-hosted app
- ▸ Why not the usual demo setups
- ▸ The flow
- ▸ The pieces worth stealing
- ↳ 1. Gate it behind an env var, and 404
- ↳ 2. Random slug, and the prefix is the contract
- ↳ 3. Seed data that never goes stale
- ↳ 4. Session handoff without touching the auth core
- ↳ 5. Cleanup that is allowed to be crude
- ▸ Guardrails, summarized
- ▸ The takeaway
SOCIAL SHARE CARD GENERATOR