I maintain NeNe Invoice, a self-hosted invoicing OSS (PHP + SPA). Recently I needed to show it to people who are not engineers — and nothing kills a demo faster than "first, create an account."
What I wanted:
- A URL you click. No signup, no login screen, no instructions.
- Realistic data already inside — invoices, payments, overdue items.
- No way for one visitor to see another visitor's mess.
Here is the demo, live (heads-up: the UI is Japanese — the product targets Japanese small businesses):
- https://invoice.ayane.co.jp/demo/kensetsu (construction company)
- https://invoice.ayane.co.jp/demo/bldmainte (building maintenance, recurring invoices)
- https://invoice.ayane.co.jp/demo/seisaku (creative agency, withholding tax)
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.
Why not the usual demo setups
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 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
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 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
The 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.
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.
3. Seed data that never goes stale
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.
4. Session handoff without touching the auth core
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:
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
(That's a real response from the live demo.) The SPA loads, does its normal silent-refresh against /{slug}/auth/refresh, gets an access token, and renders. Token rotation, cookie contents, auth middleware: all untouched.
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.
5. Cleanup that is allowed to be crude
A cron job runs hourly:
- delete
demo-orgs older thanDEMO_TTL_HOURS(default 3) -
also delete the oldest ones beyond
DEMO_MAX_ORGS(default 200)
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 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
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: src/Demo/ and tools/sweep-demo.php in NeNe Invoice (MIT).
Top comments (2)
The part that stands out here is how little demo-specific logic you needed once the tenancy and auth boundaries were already real. The 404 for /demo/* outside the demo instance, the path-scoped cookies, and the demo- prefix as a garbage-collection boundary are all the kind of boring structural choices that keep a convenience feature from becoming an accidental production liability. I also like the point that a disposable environment changes the cleanup philosophy completely: once the data is intentionally throwaway, the reset path gets simpler instead of more magical. The seeded relative dates and one intentionally messy reconciliation case are a nice product touch too, because believable demo data usually matters more than polished fake data.
Thanks — you've read the design exactly as intended, especially the point about the cleanup philosophy.
One thing I'd add: building the demo turned out to be an accidental audit of the boundaries themselves. My working rule was that the glue layer wasn't allowed to modify the auth core — and the one place I was tempted to break that rule (making the session survive reloads) is precisely where I chose to accept a limitation instead. If a "convenience feature" can't be built without reaching into the core, that's usually the core telling you something about its boundaries. This time it held, and the one-shot session became the reset button for free.
And yes on believable data — the messy reconciliation case wasn't invented for the demo. It's lifted from the exact situation the product exists to solve (bank transfer payer names that never quite match the client name). Demo data that carries a real operational pain seems to land better than any amount of polished fake records.