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.