Local Environment Seeders in Laravel

• 5 min read

Database Seeders are a feature of Laravel you don't read much about.

I barely used them before 2022, but last year I found a perfect use case that I've now started to incorporate in all my projects: "Local Environment Seeders".

Instead of only seeding data for a Category-model, the seeders generate users, teams, posts, purchases, subscriptions or what else is important in the application.

This makes not just testing the UI of an app easier, but also makes onboarding new developers to a project simpler. You don't have to share a dump of your local database – or even from staging or production – with the new developer, but can just tell them to run php artisan db:seed in their terminal.

Let me show you how I defined these seeders in two of my projects.

The first thing I do is create the seeder through an artisan command:

php artisan make:seeder LocalEnvironmentSeeder

In the default DatabaseSeeder I then add an if-condition to run the new LocalEnvironmentSeeder if the app is in the local-environment.

// database/seeders/DatabaseSeeder.php
public function run()
{
    if (app()->environment('local')) {
        $this->call(LocalEnvironmentSeeder::class);
    }
}

Within the new seeder, I usually create a "default"-user. It comes in handy when you just want to reset the state of the database, but don't want to go through the registration flow again and again.

In Laravel apps using Jetstream with teams, I create and attach one or more default teams to the user.

class LocalEnvironmentSeeder extends Seeder
{
    public function run()
    {
        $this->createDefaultUser();
    }

    protected function createDefaultUser(): self
    {
        User::factory()->create([
            'name' => 'system-user',
            'email' => 'system@example.com',
        ]);
        
        return $this;
    }
}

From here on out the seeder will differ from app to app. But what each LocalEnvironmentSeeder in my apps shares, is that they simulate different edge cases and states a user or the app can be in.

For example, the code below shows the seeder from screeenly-v3.

The seeder creates the default user, ApiUsage instances for the last 90 days and users in many different states: on trial, subscribed, expired trial, expired subscriptions, users with maxed out usage or users with many personl access tokens.

public function run()
{
    $this->createDefaultUser();
    $this->createApiUsageModels();

    $this->createUsersOnTrial();
    $this->createUsersSubscribedToDifferentPlans();
    $this->createUsersWithExpiredTrial();
    $this->createUsersWithExpiredSubscription();

    $this->createUsersWithSubscriptionAndMaxedOutUsageForTheNext12Months();
    $this->createUsersWithManyPersonalAccessTokens();
}

protected function createApiUsageModels(): void
{
    ApiUsage::factory()
        ->count(90)
        ->dailyFrequency()
        ->sequence(fn (Sequence $sequence) => [
            'period_start_at' => now()->subDays($sequence->index)->startOfDay(),
            'period_end_at' => now()->subDays($sequence->index)->endOfDay(),
        ])
        ->create();
}

protected function createUsersWithExpiredTrial(): self
{
    foreach (range(1, 10) as $index) {
        User::factory()
            ->expiredTrial($index)
            ->create(['email' => "expired-trial-{$index}@screeenly.com"]);
    }

    return $this;
}

protected function createUsersWithSubscriptionAndMaxedOutUsageForTheNext12Months(): self
{
    foreach (range(1, 10) as $index) {
        /** @var User $user */
        $user = User::factory()
            ->subscribedToPlan1()
            ->finishedOnboarding()
            ->create([
                'email' => "users-maxed-out-usage-{$index}@screeenly.com",
            ]);

        $period = new CarbonPeriod(
            now()->subMonths(12)->startOfMonth(),
            now()->addMonths(12)->endOfMonth(),
            '1 month'
        );

        foreach ($period as $date) {
            ApiUsage::factory()->create([
                'user_id' => $user->id,
                'usage' => 1000,
                'period_start_at' => $date->startOfMonth(),
                'period_end_at' => $date->endOfMonth(),
            ]);
        }
    }

    return $this;
}

While working on the app, I can now switch to different users and check, if the UI works as expected or if I need to redesign a particular component or screen.

screeenly itself is quite a simple app with only a handful of Models.
So here is another example from an app I've helped develop at work: an app that aggregates numbers from different advertising networks and displays them in dashboards to users.

public function run()
{
    $systemUserTeam = Team::first();

    // Create OAuthCredentials
    OAuthCredential::factory()->isSelected()->forFacebook()->create([
        'team_id' => $systemUserTeam->id,
    ]);

    // Create AdAccounts
    $activeAdAccounts = AdAccount::factory()
        ->count(5)
        ->forFacebook()
        ->isActive()
        ->create([
            'team_id' => $systemUserTeam->id,
            'timezone' => 'Europe/Zurich',
        ]);

    // Create Facebook AdCampaigns
    $activeAdAccounts->each(function (AdAccount $adAccount) use ($systemUserTeam) {
        $this->createRuleForAdAccount($adAccount);

        $campaigns = AdCampaign::factory()->count(3)->create([
            'team_id' => $adAccount->team_id,
            'ad_account_id' => $adAccount->id,
        ]);
        $pausedCampaigns = AdCampaign::factory()->isPaused()->count(2)->create([
            'team_id' => $adAccount->team_id,
            'ad_account_id' => $adAccount->id,
        ]);

        $campaigns
            ->merge($pausedCampaigns)
            ->each(function (AdCampaign $adCampaign) {
                $period = CarbonPeriod::create(
                    now()->subDays(28),
                    '1 day',
                    now()->addDays(28)
                );

                foreach ($period as $date) {
                    $insight = AdCampaignInsights::factory()->create([
                        'team_id' => $adCampaign->team_id,
                        'ad_campaign_id' => $adCampaign->id,
                        'date' => $date->format('Y-m-d'),
                    ]);

                    dispatch_sync(new AggregateAdCampaignsInsightsJob($insight));
                }
            });
    });
}

public function createRuleForAdAccount(AdAccount $adAccount)
{
    $pauseAdCampaignAction = Action::where('slug', 'pause-ad-campaign')->first();

    /** @var Rule $rule */
    $rule = $adAccount->rules()->create([
        'team_id' => $adAccount->team_id,
        'name' => 'Pause AdCampaigns if margin percentage <= 30% and has at least 1 conversion',
        'level' => 'ad_campaign',
    ]);
    $rule->conditions()->create([
        'column' => 'margin_percentage',
        'operator' => '<=',
        'values' => 30,
    ]);
    $rule->conditions()->create([
        'column' => 'total_conversions',
        'operator' => '>=',
        'values' => 1,
    ]);
    $rule->actions()->sync($pauseAdCampaignAction);
}

As you can see, there is a lot going on.

  • models are attached to a team and not to a particular user.
  • lots of relationsships are generated for AdCampaign-models.
  • models are created for date ranges using CarbonPeriod.
  • a job is executed in the seeder, as we didn't want to re-create the aggregate logic in the seeder. (The job is usually queued, here we use dispatch_sync to run it immediately.)1

That is it. Nothing ground breaking, but I thought I blog about this as I haven't come across the concept in written form before.

Investing the time to write an inital LocalEnvironmentSeeder and maintain it over time has become a valuable part in the developer experience™ on the projects I work on.2

There have been plenty of times where I hadn't worked on an app for a while and wasn't sure if my local database was up to date. Now a "good state" is just a php artisan migrate:fresh --seed – I've aliased it to mfs – away.


  1. As the whole point of having a LocalEnvironmentSeeder is to create a state of your local database that behaves similar to your production environment, I think it's totally fine to dispatch a job within a seeder. 

  2. Adding a LocalEnvironmentSeeder to a project that existed for a long time can take quite a while. Cumulative I think I spent 8 hours to create a first version for a seeder for an app we've maintained for 12 years now. 

Webmentions What are webmentions?

    Likes and more

    Replies

    • Photo of Scott Keck-Warren (@scottkeckwarren@phpc.social) Scott Keck-Warren (@scottkeckwarren@phpc.social) mentioned

      "Local Environment Seeders in Laravel" by @_stefanzweifel This is a really cool idea. We have something simlar but it's not using the seeder logic because we originally wrote it before seeders existed. stefanzweifel.dev/posts/2023/01/…

      February 1st, 2023

    • Photo of Laravel Daily Laravel Daily mentioned

      Local Environment Seeders in Laravel stefanzweifel.dev/posts/2023/01/…

      February 1st, 2023

    • Photo of Silvan Hagen ⚡️ Silvan Hagen ⚡️ mentioned

      Great approach, especially the little condition with the local env check.

      January 31st, 2023

    • Photo of Liam Hammett Liam Hammett replied

      Interesting distinguishing them as local - I usually only ever treat seeders as if they're local anyway, any other "seeded" data required for prod goes into a migration for me. I'm curious what cases you'd use a seeder in production?

      January 31st, 2023

    • Photo of Ed Grosvenor Ed Grosvenor replied

      We slapped Laravel on top of a legacy database about 5 years ago and I didn't even bother to try. I just pull down a dump of the production database, anonymize the data, reduce it so that it's not stupidly large, and use that for local development. It's messy. This is way better.

      January 31st, 2023

    • Photo of Stefan Zweifel Stefan Zweifel replied

      I used the `--class` flag before, but adding a condition was so much easier than remembering the command. :)

      January 31st, 2023

    • Photo of Stefan Zweifel Stefan Zweifel replied

      Prob. for what you're using migrations. We only keep structure changes in migrations. We have a project where we run seeders in prod, to keep a "policies" table up to date (think Terms of Service). Creating migrations to add or update a policy wasn't something we wanted to do.

      January 31st, 2023

    • Photo of Stefan Zweifel Stefan Zweifel replied

      Yay! I'm not alone who does that. Back-adding this to projects is such a pain, I can tell you. Still not finished adding the seeder to a 12 year old project that has like 90 models.

      January 31st, 2023

    • Photo of Stefan Zweifel Stefan Zweifel replied

      I think so, yeah. It's probably obvious to a lot of people to use seeders that way. Didn't/couldn't use them in my projects in the past and only recently realised how good they are. 🫠

      January 31st, 2023

    • Photo of David Torras David Torras replied

      Thanks for sharing. Our approach is almost the same, but a little piece that makes everything easier and I'll add to our projects. How have I never thought about adding this? 😅

      January 31st, 2023

    • Photo of Ed Grosvenor Ed Grosvenor replied

      I love this approach. We're doing the same thing for a new app we're developing at work and by setting this all up right away we're going to save ourselves a ton of time and headache in the future.

      January 31st, 2023

    • Photo of Ruslan Steiger Ruslan Steiger replied

      Dope idea. I should implement something like that as well 💪 and love your `mfs` alias 😉

      January 31st, 2023

    • Photo of Dennis Koch Dennis Koch replied

      Isn't this the original idea behind seeders? Seeding test data for development?

      January 31st, 2023

    • Photo of Nicola Lazzaro 🇮🇹 Nicola Lazzaro 🇮🇹 replied

      Thanks for sharing, I hadn't thought about encapsulating the factories in a seeder for the development environment. I like it.

      January 31st, 2023