Database Seeding Strategies That Actually Help in Production SaaS Apps
If you've been building Laravel apps for any length of time, you've almost certainly written a seeder. You've probably also watched a junior dev — or honestly, your past self — accidentally run db:seed against a production database and feel that particular cold dread wash over them.
Seeders get a bad reputation. They're often treated as a throwaway dev tool: chuck some fake users in, maybe wire up some test plans, done. But over 25 years of building software — and a good chunk of that building SaaS products in Laravel — I've come to think of seeders as a genuinely underused part of the toolkit. Done well, they can help with everything from onboarding new team members to powering demo environments to safely bootstrapping required data in production.
Let me walk you through how I think about seeding now, and the patterns I keep reaching for.
Separating Your Seeders By Purpose
The first shift that made everything click for me was treating different types of seed data as genuinely different concerns. I now split seeders into three categories:
Required data — things that must exist for the app to function at all. Think subscription plans, permission roles, system configuration records. This data belongs in production.
Demo data — a realistic, polished snapshot of what a busy account looks like. Great for sales demos, staging environments, and onboarding screenshots.
Development data — the messy, volume-heavy stuff. Thousands of records, edge cases, weird unicode names, all the things you need to stress-test your UI and queries locally.
Once I started separating these, I stopped being scared of seeders near production, because the required data seeders are genuinely safe to run — they're idempotent by design.
Making Required Seeders Idempotent
This is the big one. A required seeder should be safe to run multiple times without creating duplicates or breaking anything. Laravel's updateOrCreate and firstOrCreate are your friends here.
public function run(): void
{
$plans = [
['slug' => 'starter', 'name' => 'Starter', 'price' => 1900],
['slug' => 'pro', 'name' => 'Pro', 'price' => 4900],
['slug' => 'business', 'name' => 'Business', 'price' => 9900],
];
foreach ($plans as $plan) {
Plan::updateOrCreate(
['slug' => $plan['slug']],
$plan
);
}
}
I wire these up in a RequiredDataSeeder class that gets called from DatabaseSeeder only when a flag is set, or I'll call it explicitly during deployment. In my deploy.php (or in Forge's deployment script), I'll run:
php artisan db:seed --class=RequiredDataSeeder
This means any time I add a new plan tier or a new system role, it gets seeded consistently across every environment, including production, without me having to write a one-off migration just to insert a row.
Using Factories Properly for Demo and Dev Data
Laravel's model factories are brilliant, and Faker has come a long way. For demo seeders, I want data that looks believable — real-ish company names, sensible dates, a mix of active and churned accounts.
I'll often create a dedicated DemoSeeder that tells a story:
public function run(): void
{
// An owner account with a full team
$owner = User::factory()
->withTeam()
->create(['name' => 'Sarah Mitchell']);
// Some historical activity
Project::factory()
->count(12)
->for($owner->currentTeam)
->completed()
->create();
// A couple of active projects with tasks
Project::factory()
->count(3)
->for($owner->currentTeam)
->withTasks(10)
->create();
}
The key here is using factory states (completed(), withTasks()) to keep the seeder itself readable. The complexity lives in the factory, where it belongs.
The Demo Environment Pattern
For SaaS products, I've started maintaining a dedicated demo environment — not just staging — that gets re-seeded on a schedule (usually nightly). This gives sales and marketing a consistently fresh, realistic environment to show prospects without any real customer data.
The setup is simple: a separate app instance pointing at its own database, with a scheduled command that wipes and re-seeds:
Schedule::command('migrate:fresh --seed --seeder=DemoSeeder')
->dailyAt('02:00')
->environments(['demo']);
Yes, migrate:fresh in a scheduled command feels a bit wild. But scoped to the demo environment, it's fine — and it means your demo is always clean, always up to date with your latest schema, and always populated with data that reflects your current feature set.
Structuring the DatabaseSeeder for Clarity
My DatabaseSeeder has ended up looking something like this:
public function run(): void
{
$this->call(RequiredDataSeeder::class);
if (app()->environment(['local', 'development'])) {
$this->call(DevDataSeeder::class);
}
if (app()->environment('demo')) {
$this->call(DemoSeeder::class);
}
}
Clean, explicit, and environment-aware. No more accidentally seeding dev junk into staging.
One More Thing: Seeding for Tests
I'll keep this brief because it deserves its own post, but it's worth mentioning: for feature tests, I lean heavily on factories directly in test setup rather than running full seeders. Seeders are slow, and tests should be fast. The one exception is RequiredDataSeeder — I'll run that in my TestCase base class or use RefreshDatabase alongside a trait that calls it once per test suite.
Wrapping Up
Seeders aren't glamorous, and they're rarely what people mean when they talk about Laravel's killer features. But getting this right has genuinely saved me time, reduced production anxiety, and made onboarding new developers onto a project dramatically smoother.
If your seeder strategy right now is "run it locally, never touch it again" — I'd encourage you to spend an hour pulling it apart into these three categories. You'll be surprised how much calmer deployments feel when your required data is just... handled.
As always, if you've got a different approach or something that's worked well for you, I'd love to hear about it.