Seed your demo data through the same code your users run
Sooner or later a side project needs a demo. Not the local seeder you use to click around while developing, but a hosted copy you can hand to a prospect on a call and say “go ahead, log in.” I needed one recently for a compliance tracking app, and the obvious approach (a seeder that inserts a pile of rows) turned out to be the wrong one.
The problem with inserting rows directly is that a demo isn’t just data on a screen. It’s the whole app behaving the way it will for a real customer. If the seeder skips the code paths that real users go through, the demo quietly stops being the product.
Raw inserts lie about your app
My app has a few rules that live in its write paths, not in the database:
- Every change writes an audit entry, and those entries are chained so tampering is detectable. There’s a verify command that walks the chain.
- Publishing a policy creates a new immutable version with a content hash.
- Uploading a document validates it, stores it, and links it to the right record.
A seeder that does Buyer::insert([...]) skips all of that. The dashboard might look fine,
but the audit log is empty, the chain check fails, and the first time someone clicks
“download document” they get a 404 because there’s a database row and no file. You find
out about each of these live, in front of the person you were trying to impress.
So the rule I follow now: the demo seeder calls the same actions the controllers call.
If the app has a PublishPolicy action, the seeder uses it. If every status change records
an audit entry, the seeder records one too, through the same helper.
$policy = app(PublishPolicy::class)->handle($owner, [
'title' => 'Sample Policy',
'body' => 'Fictional demo policy.',
'effective_on' => $today->subDays(14)->toDateString(),
]);
foreach ($members as $i => $member) {
if ($i < 7) { // leave a few people who have NOT acknowledged it
app(AcknowledgePolicy::class)->handle($member, $policy, '127.0.0.1', 'demo:seed');
}
}
This is slower than a bulk insert, and for 30 records nobody will ever notice. In exchange the demo exercises the real invariants, and if one of those actions changes later, the demo changes with it instead of drifting.
Seed the problems, not just the happy path
The second mistake is making every record perfect. A demo where everyone is compliant and every document is signed shows off nothing, because the whole point of the product is to surface the cases that aren’t fine.
I think about the demo as a story the dashboard should tell in the first ten seconds, then I seed each beat on purpose:
- A few people who haven’t acknowledged the current policy.
- One agreement that already expired, and two that expire within 30 days.
- One record marked signed with no document uploaded, which is exactly the gap a reviewer wants flagged.
- A handful that are partway through, and a few that haven’t started.
A match (true) on the index keeps that readable and makes the distribution obvious to the
next person who opens the file:
[$status, $signedOn, $expiresOn, $hasDoc] = match (true) {
$i === 0 => ['signed', $today->subDays(120), $today->subDays(3), true], // expired
$i === 1 => ['signed', $today->subDays(70), $today->addDays(12), true], // expiring soon
$i === 3 => ['signed', $today->subDays(5), $today->addDays(85), false], // signed, no document
$i < 10 => ['signed', $today->subDays(10 + $i), $today->addDays(90 + $i), true],
$i < 15 => ['in_progress', null, null, false],
default => ['none', null, null, false],
};
Notice the dates are all relative to “today.” A demo seeded with fixed dates looks great for a month and then everything is expired. Relative dates keep the story true no matter when you rerun it. If your app has a per-account time zone, compute “today” in that zone, or the expired record can flip to not expired depending on what hour you seed.
Make it safe to run on the wrong machine
A demo seeder is a command that creates users with a known password. That deserves a couple of guard rails:
- Refuse to run in production unless asked. The hosted demo is technically a production
environment, so the command checks
app()->isProduction()and requires an explicit--force. Nobody runs it against a real customer database by accident. - Make it idempotent. If the demo account already exists, print “nothing changed” and exit successfully. Rerunning a deploy script shouldn’t create a second fake company or fail the deploy.
- Wrap it in one transaction. If something throws halfway through, you get no demo rather than half a demo that the idempotency check then treats as done.
- Use obviously fictional data. Invented names,
exampledomain emails (that domain is reserved for exactly this), and “(Demo)” in the account name so a screenshot can’t be mistaken for a real customer.
if (app()->isProduction() && ! $this->option('force')) {
$this->error('APP_ENV=production. Re-run with --force only on the hosted demo.');
return self::FAILURE;
}
if (Account::where('name', self::ACCOUNT)->exists()) {
$this->warn('Demo account already exists; nothing changed.');
return self::SUCCESS;
}
DB::transaction(fn () => $this->build());
Files deserve the same care as rows. If the app expects uploaded PDFs, the seeder should upload real ones through the real upload path. I generate a tiny valid one page PDF in code (a few objects and a correct cross reference table is all a viewer needs), so there’s no binary fixture in the repo and the download button opens something.
Test the demo like a feature
The demo is the first thing a prospect sees, so it gets a feature test. Mine runs the command against a fresh database and asserts the things I’d be embarrassed by on a call:
- The expected number of accounts and users exist, and the printed login actually works
(post to
/loginand assert you’re authenticated). - Each status in the story is present: expired, expiring soon, missing document, unacknowledged.
- The audit chain verifies. This is the assertion that would fail immediately if someone “optimized” the seeder back to raw inserts.
- Running it a second time succeeds and still leaves exactly one demo account.
That last pair is my favorite part. The test documents why the seeder is written the way it is, so the rule survives longer than my memory of it.
Tradeoffs
- It’s slower. Going through actions, events, and file storage is much slower than a bulk insert. That’s fine for dozens of records. For load testing with thousands, write a separate factory based seeder and don’t call it a demo.
- Side effects come along. Real actions might send email or call webhooks. Fake the mailer or point it at a log driver in the demo environment, or you’ll notify a fictional person at a reserved domain that bounces.
- A known password is still a password. Print it once, keep the demo account scoped to its own fictional data, and don’t reuse that password anywhere.
The checklist I’ll reuse
- Build demo data through the app’s own actions so invariants like audit logs hold.
- Seed the messy cases deliberately, with dates relative to today in the account’s time zone.
- Require
--forcein production, exit cleanly if the demo exists, and use one transaction. - Fictional names,
exampledomain emails, and a visible “(Demo)” label. - Real files through the real upload path.
- A feature test that logs in, checks each story beat, verifies integrity, and reruns.