0x55aa
Back to Blog

Laravel Factories & Seeders: Your Test Data Is Lying to You ๐Ÿญ

|
6 min read

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 relationships
  • has() and for() 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. ๐ŸŒฑ๐Ÿ’จ

Share:LinkedInXHacker News

Related reading

Thanks for reading!

Back to all posts