Skip to content
Tech

Database Seeding and Factories: Building Realistic Test Data in Laravel

Practical patterns for writing Laravel factories and seeders that produce data realistic enough to catch real bugs.

Database Seeding and Factories: Building Realistic Test Data in Laravel
Database Seeding and Factories: Building Realistic Test Data in Laravel
In this article

    Factories and seeders are often treated as throwaway scaffolding, written once and never revisited. That is a missed opportunity. Well-built factories double as living documentation of what a valid record looks like, and they make tests far more likely to catch bugs that only show up with realistic data shapes, such as long strings, null-able foreign keys, or specific enum combinations.

    Why Seeders and Factories Matter Beyond Tests

    Factories are usually introduced for feature tests, but they earn their keep just as much in local development. A seeded database with a few hundred realistic orders, products and users lets you click through the UI the way a real user would, instead of testing against three records you created by hand in Tinker. The investment in a good factory pays off every time someone resets their local database.

    Writing Factories That Model Real Data

    A factory's job is to produce a valid record, not necessarily a realistic-looking one — but the two are easy to combine with Faker's formatters. Resist the temptation to hardcode the same three values for every field; varied data surfaces edge cases like string truncation or sort-order bugs.

    class ProductFactory extends Factory
    {
        public function definition(): array
        {
            return [
                'name' => ucfirst(fake()->words(3, true)),
                'sku' => strtoupper(fake()->bothify('??-####')),
                'price_cents' => fake()->numberBetween(500, 25000),
                'stock' => fake()->numberBetween(0, 200),
                'is_active' => fake()->boolean(85),
                'category_id' => Category::factory(),
            ];
        }
    }

    Notice is_active is weighted toward true rather than a plain coin flip. Matching the real-world distribution of your data — most products active, most orders completed, a minority of edge cases — makes seeded environments behave more like production.

    States and Relationships

    States let a factory express variations without duplicating the base definition. They compose well and read clearly at the call site.

    class OrderFactory extends Factory
    {
        public function definition(): array
        {
            return [
                'user_id' => User::factory(),
                'status' => 'pending',
                'total_cents' => fake()->numberBetween(1000, 50000),
            ];
        }
    
        public function completed(): static
        {
            return $this->state(fn (array $attributes) => [
                'status' => 'completed',
                'completed_at' => fake()->dateTimeBetween('-30 days'),
            ]);
        }
    
        public function withItems(int $count = 3): static
        {
            return $this->afterCreating(function (Order $order) use ($count) {
                OrderItem::factory()->count($count)->create(['order_id' => $order->id]);
            });
        }
    }
    
    // usage
    Order::factory()->completed()->withItems(5)->create();

    Keeping relationship setup in afterCreating hooks instead of the base definition() avoids accidentally creating child records every single time, even for tests that do not care about them.

    Seeding for Local Development vs CI

    Local development seeding and CI seeding usually have different goals. Local seeding wants volume and variety so the application feels populated. CI seeding wants speed and determinism so tests run quickly and produce the same result every time. It is worth keeping these as separate seeder classes rather than one that tries to do both.

    class DatabaseSeeder extends Seeder
    {
        public function run(): void
        {
            if (app()->environment('local')) {
                User::factory(50)->has(Order::factory()->count(3))->create();
                Product::factory(200)->create();
                return;
            }
    
            // Minimal, deterministic data for CI and tests
            User::factory()->create(['email' => '[email protected]']);
        }
    }
    Tip: Use $this->call() to split seeders by domain (UserSeeder, ProductSeeder, OrderSeeder) once the database seeder grows past a screen or two. It keeps each piece independently runnable with php artisan db:seed --class=ProductSeeder.

    Keeping Seeders Idempotent

    A seeder that can only run once against a fresh database is fragile. Favor firstOrCreate() or updateOrCreate() for reference data such as roles, countries or plan tiers, so the seeder can be re-run safely after a partial failure or a schema change.

    collect(['admin', 'editor', 'viewer'])->each(
        fn (string $name) => Role::firstOrCreate(['name' => $name])
    );
    Seeders you can safely re-run are seeders your teammates will actually trust enough to use.

    Wrapping Up

    Treat factories as a specification of what a valid record looks like, not just a test utility. Vary the data realistically, use states to express variations explicitly, and separate bulk local seeding from the minimal, deterministic data your CI pipeline depends on. The time spent here comes back every time someone needs a working database without writing SQL by hand.

    Written by Raj

    Founder & Editor

    I write HorizonDesk: practical, no-hype articles on technology, money, business and the vehicles we drive. Everything here is something I’ve researched, tested or wanted explained clearly myself.