DEV Community

Cover image for Your Seeder Should Describe a Shape, Not Your Laptop
Nasrul Hazim
Nasrul Hazim

Posted on

Your Seeder Should Describe a Shape, Not Your Laptop

TL;DR

  • Rewrote a bootstrap seeder so the deployment stack — DB engine, queue runner, PHP version — is config, not hardcoded.
  • Renamed it away from a phase-based name (MvpSeeder). If a class name describes when you wrote it, it expires.
  • Trimmed a CI matrix from three PHP versions to one. Matrix breadth should match what you actually deploy.

The seeder that only worked for me

Bootstrap seeders rot predictably. Version one seeds exactly the environment the author had open at the time: one database engine, one queue setup, one PHP version. It works, so nobody touches it. Six months later someone needs Postgres and discovers the choice was never a choice.

I hit that on a control-plane app — the thing that describes and provisions deployments. Its seeder encoded a single stack shape, so I pulled the variable parts into config.

'control_plane' => [
    'default_blueprint' => 'stack-laravel',   // or -ha, -single-node
    'database'    => env('CONTROL_PLANE_DB', 'mysql'),      // mysql | postgresql
    'queue'       => env('CONTROL_PLANE_QUEUE', 'horizon'), // horizon | default
    'php_version' => env('CONTROL_PLANE_PHP', '8.4'),

    'apps' => [
        // each entry may override blueprint / queue per app
    ],
],
Enter fullscreen mode Exit fullscreen mode
Knob Options Default from
Blueprint single-node, standard, HA config
Database MySQL, PostgreSQL env
Queue runner Horizon, plain queue:work env
PHP runtime 8.3 – 8.5 env

Two rules kept it tidy. Every knob has a sane default, so a fresh clone still seeds without a populated .env. And every knob is overridable per app — the moment you have more than one workload, one will be the exception.

   config/seeder.php
        |
        v
   PrepareSeeder  ---->  ControlPlaneSeeder
                              |
                +-------------+-------------+
                v             v             v
           blueprint       database      queue runner
        (single/std/ha)  (mysql/pgsql)  (horizon/default)
                \             |             /
                 +-----> Workload + Deployment
Enter fullscreen mode Exit fullscreen mode

Horizon or not is a real fork

Both options run under supervisord, but they're different operational bargains:

Horizon Plain queue:work
Dashboard Yes No
Auto-balancing across queues Yes Manual worker counts
Extra dependency Redis required Any queue driver
Config surface config/horizon.php Supervisor program blocks

Horizon is the better default for real queue load. But a single-node app running three jobs a day doesn't need Redis in the stack just for a dashboard nobody opens. Per-app key means neither choice forks the codebase.

Rename anything named after a phase

The seeder used to be MvpSeeder. It's now ControlPlaneSeeder.

MvpSeeder answers "when was this written". ControlPlaneSeeder answers "what does this do". The first stops being interesting the moment the MVP ships; the second stays true for the life of the class. Same for NewUserService, V2Controller, TempJob — timestamps pretending to be names.

While I was in there: the CI matrix

Related cleanup elsewhere. A test workflow was running PHP 8.3, 8.4 and 8.5. Trimmed to 8.5.

Matrix breadth should match your deployment surface. A published package genuinely needs to prove it works across versions — its users pick the runtime, not you. An application you deploy yourself runs exactly one PHP version, and testing two others buys nothing but Actions minutes.

Project type Matrix
Published package Every supported PHP/framework combo
Deployed application The version you actually deploy

Also removed a duplicate workflow running the same suite twice per push. Free 50% cut, which is the best kind.

Test the knobs

Seeders are worth a test the moment they encode decisions rather than fixtures:

it('seeds the queue runner from config', function () {
    config(['seeder.control_plane.queue' => 'default']);

    $this->seed(ControlPlaneSeeder::class);

    expect(Deployment::first()->queue_runner)->toBe('default');
});
Enter fullscreen mode Exit fullscreen mode

Without it, "the config knob works" is an assumption that holds until the one time you actually flip it — usually on a fresh server at an inconvenient hour.

Takeaway

A seeder is documentation that executes. If it says "this system runs MySQL with Horizon on PHP 8.4", it should be describing a default, not a constraint. Push the specifics to config, keep the shape in the class, and give every knob a default that works out of the box.

Top comments (0)