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
],
],
| 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
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');
});
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)