nicodemuz/doctrine-fixtures-timing-bundle
A Symfony bundle that times Doctrine fixture loading, helping you spot slow data fixtures during development and testing.
First, correct the classification before you write any code. This is a Symfony bundle (-bundle suffix, registered in config/bundles.php, listening on Symfony console/kernel events). Laravel has no bundle registry, no doctrine/doctrine-fixtures-bundle in the default stack, and no shared event lifecycle with it. Do not composer require this into a Laravel app expecting it to work — you will get a package that installs cleanly and does nothing.
Where to look first. Clone the repo and read, in this order:
composer.json — the real support matrix (PHP, Symfony, doctrine/doctrine-fixtures-bundle major version). The v1.0.0 release notes are an auto-generated changelog link with no body, so this file is your only compatibility source of truth.console.* event, or Doctrine's fixture-load events. If it's console-only, it is portable in spirit; if it's Doctrine-internal, close the ticket.microtime()/hrtime() around fixture execution (portable, ~50–100 lines) or a DebugStack/Symfony Profiler tie-in (not portable)?Your first use case is not adopting the package — it's answering "which of my seeders is slow?" That question is answerable natively in an afternoon. Start here:
// database/seeders/Concerns/TimesExecution.php
namespace Database\Seeders\Concerns;
trait TimesExecution
{
protected function timeIt(string $label, callable $fn): mixed
{
$start = hrtime(true);
try {
return $fn();
} finally {
$ms = (hrtime(true) - $start) / 1e6;
app(\App\Support\FixtureTimings::class)->record($label, $ms);
}
}
}
// database/seeders/DatabaseSeeder.php
public function run(): void
{
$this->timeIt(static::class, fn () => parent::run());
// or per-call:
$this->timeIt(ProductSeeder::class, fn () => $this->call(ProductSeeder::class));
}
Then dump the report from a terminating callback on the db:seed command. That is the whole value proposition, and you own it.
Only run the vendor spike if you genuinely need the reference implementation. Timebox it to a day in a throwaway Symfony skeleton, purely to read the real instrumentation code. Do not let this gate a fixture-performance initiative.
Override run() once in a base class; every seeder inherits timing for free. This mirrors what the bundle does for Doctrine fixtures and is the smallest-surface integration.
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Support\FixtureTimings;
abstract class TimedSeeder extends Seeder
{
public function __invoke(array $parameters = []): mixed
{
$start = hrtime(true);
try {
return parent::__invoke($parameters);
} finally {
FixtureTimings::record(
static::class,
(hrtime(true) - $start) / 1e6,
);
}
}
}
Good for CI: capture total db:seed wall time and print a top-N report on CommandFinished. This is the Laravel equivalent of the bundle's console-level hook.
// app/Providers/AppServiceProvider.php (register only in local/testing)
use Illuminate\Console\Events\CommandFinished;
use Illuminate\Support\Facades\Event;
Event::listen(CommandFinished::class, function (CommandFinished $event) {
if (! in_array($event->command, ['db:seed', 'migrate:fresh'], true)) {
return;
}
FixtureTimings::report($event->output);
});
Duration alone tells you what is slow; query counts tell you why. Pair the timer with DB::listen scoped to the seeder run.
DB::listen(function (QueryExecuted $q) use (&$stats) {
$stats['queries']++;
$stats['ms'] += $q->time;
});
The genuinely valuable use of per-fixture timings is a threshold check, not a pretty table. Emit JSON from the timing collector and assert on total fixture time in CI:
// tests/Feature/FixturePerformanceTest.php
public function test_fixture_suite_stays_within_budget(): void
{
$this->seed();
$total = FixtureTimings::total();
$this->assertLessThan(30_000, $total, "Fixture suite took {$total}ms");
}
Keep the budget loose and the assertion about totals; per-fixture assertions are flaky on shared CI runners.
If the Symfony source turns out to be a thin microtime wrapper, port the reporting format (sorted descending, top-N, percentage of total) rather than the code. The formatting is the part that saves you debugging time; the timing is three lines.
app()->environment(['local', 'testing']) — never in production.php artisan migrate:fresh --seed rather than db:seed alone — RefreshDatabase timing without migrations is misleading.The bundle will not work in Laravel. Ever. It needs config/bundles.php registration and Symfony's event dispatcher. If you see a PR adding it to composer.json, that is the bug. Correct the classification at the planning stage — mid-sprint discovery is the most expensive failure mode here.
Silent non-integration is the top failure mode even in Symfony. A listener that registers but never fires produces empty output, which reads exactly like "fixtures are fast." If you port the pattern, assert the collector actually recorded N entries; don't trust an empty report.
Nested $this->call() double-counts. DatabaseSeeder::run() calling ProductSeeder means the parent's wall time includes the child's, and your report will show the parent as the bottleneck. Either instrument only leaf seeders, or record both self-time and inclusive-time and label them distinctly. The Symfony bundle has the same conceptual issue with fixture dependencies — check how it handles getDependencies().
hrtime(true) not microtime(true). You're measuring sub-millisecond fixture bodies; microtime is wall-clock and will wobble under NTP. Return nanoseconds and convert at the boundary.
RefreshDatabase distorts everything. The trait wraps each test in a transaction and runs migrations once; fixture timings inside a transactional test are not representative of a cold migrate:fresh --seed in CI. Measure CI seeding in a dedicated job, not in the test suite.
--class vs default. php artisan db:seed runs only DatabaseSeeder; --class=ProductSeeder runs one. If your instrumentation hooks the command rather than the seeder, --class runs produce a report with one entry and no nesting context. Hook the seeder, report at the command.
Query logging will dominate your memory profile. DB::listen accumulates per-query; on a 500k-row seeder this is hundreds of MB. Aggregate into counters inside the listener (as in Pattern 3) and never store the query objects.
Doctrine fixtures ≠ Eloquent factories. If your team runs laravel-doctrine, the fixture runner is still not doctrine-fixtures-bundle in the typical wiring. Verify which runner actually executes before assuming any Doctrine-domain tooling applies.
Abandonment risk is real. Single author, zero dependents, 1.0.0 shipped with no release narrative. If you somehow do take a dependency, pin ~1.0 exactly and document the fallback ("reimplement in app/Support/FixtureTimings.php") in the PR description. Never let it become load-bearing for a shared workflow.
Extension points worth stealing, not depending on: the per-fixture label format, the sorted-descending report, and any "top N" truncation. Those are UX decisions baked into ~100 lines you can copy in an afternoon under MIT.
Debugging your own collector: if the report is empty, check in this order — (1) is the timing env-gated and the env actually local/testing? (2) is the base class actually extended by the seeder being run? (3) is DatabaseSeeder::run() calling parent::run() or short-circuiting? (4) did the terminating callback register before the command finished?
How can I help you explore Laravel packages today?