Laravel Factories & Seeders: Your Test Data Is Lying to You 🏭
Laravel Factories & Seeders: Your Test Data Is Lying to You 🏭
If your test database has 3 users named "Test User", one product called "Product 1", and no edge cases — congratulations, you're writing tests for a world that doesn't exist.
I learned this the hard way. We launched an e-commerce feature at Cubet that worked perfectly in staging. Real users hit it and immediately found a bug because one of them had an apostrophe in their name (O'Brien). Our test data had zero apostrophes. Our test suite was green. Our Slack was not. 🔥
Let's fix this properly.
The Problem With Lazy Test Data 😴
This is what most devs write:
// The "I'll fix it later" seeder
User::create(['name' => 'Test User', 'email' => '[email protected]']);
User::create(['name' => 'Test User 2', 'email' => '[email protected]']);
This tests precisely nothing real. Real users have long names, unicode characters, weird email providers, and they definitely have "O'" in their surnames.
Real Talk: If your test data looks nothing like production data, your tests are theatre. They feel good but they're not catching real bugs.
Laravel Factories: The Right Way 🎯
Laravel factories use Faker to generate realistic data automatically. Here's what a proper factory looks like:
// database/factories/UserFactory.php
class UserFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'phone' => fake()->phoneNumber(),
'country' => fake()->countryCode(),
'email_verified_at' => now(),
'password' => bcrypt('password'),
];
}
// States make factories incredibly powerful
public function unverified(): static
{
return $this->state(['email_verified_at' => null]);
}
public function admin(): static
{
return $this->state(['role' => 'admin']);
}
public function suspended(): static
{
return $this->state(['suspended_at' => now()]);
}
}
Now your tests can do this:
// Create 50 realistic users with one line
User::factory()->count(50)->create();
// Create an unverified admin (good for edge case testing)
User::factory()->unverified()->admin()->create();
// Create a suspended user for testing access restrictions
User::factory()->suspended()->create();
In production systems I've built, this pattern has caught bugs that "happy path" tests missed every single time.
Factory Relationships: Where It Gets Fun 🔗
For our e-commerce backend, orders belong to users, items belong to orders, and addresses belong to users. Here's how we handled it:
class OrderFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(), // Creates a user automatically!
'status' => fake()->randomElement(['pending', 'processing', 'shipped', 'delivered', 'cancelled']),
'total' => fake()->randomFloat(2, 10, 5000),
'notes' => fake()->optional()->sentence(), // Randomly null or filled
];
}
}
The User::factory() call inside the definition is magic. Laravel will automatically create a related user when you create an order. No manual setup needed.
// This creates a user AND an order in one line
$order = Order::factory()->create();
// Or if you want to control the user
$order = Order::factory()->for($existingUser)->create();
// Create 10 orders with 3 items each
Order::factory()
->count(10)
->has(OrderItem::factory()->count(3), 'items')
->create();
Pro Tip: The recycle() Method 🔄
Here's something that saved us in a real project. When you create 100 orders for a realistic database, you don't want 100 different users — that's not realistic. You want a smaller pool of users placing multiple orders.
$users = User::factory()->count(20)->create();
Order::factory()
->count(100)
->recycle($users) // Reuses existing users randomly
->create();
One method call. Now your test DB looks like a real app where some users are power buyers and others placed one order two years ago.
Seeders: Orchestrating the Whole Thing 🌱
Factories are the building blocks. Seeders are the conductor.
// database/seeders/DatabaseSeeder.php
class DatabaseSeeder extends Seeder
{
public function run(): void
{
// Create admin users first
User::factory()->admin()->count(3)->create();
// Create regular users
$users = User::factory()->count(50)->create();
// Create products
$products = Product::factory()->count(200)->create();
// Generate realistic order history
Order::factory()
->count(500)
->recycle($users)
->has(
OrderItem::factory()
->count(fake()->numberBetween(1, 5))
->recycle($products),
'items'
)
->create();
$this->command->info('✅ Database seeded with realistic e-commerce data');
}
}
Run with:
php artisan migrate:fresh --seed
One command and you have a realistic database with 50 users, 200 products, 500 orders, and up to 2,500 order items. Takes about 3 seconds.
Real Talk: The Edge Cases That Will Bite You 💬
As a Technical Lead, I've learned that the bugs your test data doesn't cover will find their way to production with surgical precision. These are the states I now always test:
For users: unverified accounts, suspended accounts, accounts with no orders, accounts with 100+ orders, names with apostrophes and hyphens
For orders: zero-item orders (edge case — can happen with race conditions), cancelled orders mid-fulfillment, orders with free items (price = 0.00), orders in every status
For products: out-of-stock items, items with no images, items with names exceeding 255 chars (you'd be surprised)
Add factory states for all of these. Future you will send current you a thank-you card.
Bonus: Fake Data That's Actually Realistic 🎁
Faker has providers for almost everything. Don't settle for generic data:
// For an e-commerce app, this is way more realistic
'product_name' => fake()->randomElement([
fake()->colorName() . ' ' . fake()->word(),
fake()->company() . ' ' . fake()->word(),
]),
'price' => fake()->randomElement([9.99, 19.99, 29.99, 49.99, 99.99]),
'sku' => strtoupper(fake()->bothify('??-####-??')),
'weight_kg' => fake()->randomFloat(2, 0.1, 25.0),
And for locale-specific testing, Faker supports it:
fake('en_IN')->phoneNumber() // Indian phone numbers
fake('de_DE')->name() // German names
We used this extensively when building multi-region features. Catching "this breaks for German addresses" in staging beats finding it after launch.
TL;DR ✅
- Factories generate realistic model data using Faker — use states for edge cases
recycle()reuses existing models for realistic relationshipshas()andfor()build proper relational data with zero boilerplate- Seeders orchestrate factories into complete, believable datasets
- Your test data should be embarrassingly realistic — if it looks too clean, it's lying to you
Stop testing with [email protected] and "Product 1". Your bugs are hiding in the data you're too polite to generate.
Burned by bad test data before? Hit me on LinkedIn — I have war stories. 😄
More Laravel deep dives? Star the repo on GitHub — new posts drop regularly!
Now go seed that database like it owes you money. 🌱💨