symfony/stopwatch
Symfony Stopwatch profiles PHP code: start and stop named events, group them into sections, and record lap times to measure where execution time goes. Install via Composer.
Install — but check first. Stopwatch is often already in vendor/ because Symfony components arrive transitively:
composer why symfony/stopwatch # is it already there, and which major?
composer require symfony/stopwatch:^7.2 # floor at 7.2 for ROOT + getLastPeriod()
Pin the constraint to the Symfony major your Laravel version already resolves (Laravel 11/12 → Symfony 7 components). Forcing ^8.0 into a Symfony 7 stack will fail resolution.
Where to look first. The whole API is three classes:
Symfony\Component\Stopwatch\Stopwatch — start(), stop(), lap(), openSection() / stopSection(), getEvent(), getSectionEvents(), reset().Symfony\Component\Stopwatch\StopwatchEvent — getDuration(), getMemory(), getCategory(), getPeriods(), getLastPeriod() (7.2+), isStarted().Symfony\Component\Stopwatch\StopwatchPeriod — per-lap getDuration() (float ms) and getMemory().First use case. Don't wire middleware on day one. Instrument one queue job and one Artisan command — that's where APM coverage in a Laravel app is weakest and where a 40-line wrapper proves the value immediately.
$stopwatch = app(Stopwatch::class);
$stopwatch->start('reports.generate', 'job');
$rows = $this->fetchRows(); // ...
$this->persist($rows); // ...
$event = $stopwatch->stop('reports.generate');
logger()->channel('profiler')->info('job.profile', [
'event' => $event->getName(),
'category' => $event->getCategory(),
'duration' => $event->getDuration(), // integer milliseconds
'memory' => $event->getMemory(), // bytes
]);
1. Bind as scoped, wrap it, never touch it directly. Under Octane, queue daemons, and Swoole the container outlives the request; a singleton Stopwatch accumulates events until a worker OOMs.
// ProfilerServiceProvider::register()
$this->app->scoped(Stopwatch::class);
$this->app->scoped(Profiler::class);
Profiler owns the naming taxonomy (job.{class}, http.{route}, db.query), the enabled flag, the JSON-lines emitter, and the exit ramp to OpenTelemetry spans. Application code calls Profiler::measure('checkout', fn () => ...); nothing else imports Stopwatch. That single indirection is what makes the wrapper replaceable later.
2. Queue jobs — use job middleware, not a base class.
class ProfileJob
{
public function handle(object $job, Closure $next): mixed
{
$profiler = app(Profiler::class);
$name = 'job.'.class_basename($job);
return $profiler->measure($name, fn () => $next($job), ['queue' => $job->queue ?? null]);
}
}
Register with public function middleware(): array { return [new ProfileJob()]; } on the jobs that matter — do not put it behind a global Bus::pipeThrough() unless the sampling gate is inside the middleware.
3. Artisan commands — sections map naturally onto phases.
$stopwatch->openSection('import');
$stopwatch->start('import.fetch');
$csv = $this->fetch();
$stopwatch->stop('import.fetch');
$stopwatch->start('import.transform');
// ...
$stopwatch->stop('import.transform');
$stopwatch->stopSection('import');
$events = $stopwatch->getSectionEvents('import'); // only readable after stopSection()
4. Laps for streaming or chunked work. lap() closes the current period and opens a new one under the same event, so you get a distribution rather than a lump:
$stopwatch->start('import.persist');
foreach ($chunks as $chunk) {
$this->write($chunk);
$stopwatch->lap('import.persist');
}
$event = $stopwatch->stop('import.persist');
$slowest = max(array_map(fn ($p) => $p->getDuration(), $event->getPeriods()));
Lap counts are the cheap early-warning signal: 20,000 periods means the loop should be profiled, not the stopwatch removed.
5. HTTP middleware — sampled, staging-only, feature-flagged. One outer event plus named sub-events registered via DB::listen() and cache/HTTP-client hooks. Gate it on config('profiler.enabled') and a request-sampling rate, and only emit for the three to five routes that carry the SLO.
6. CI budget gate. A thin test helper is the difference between instrumentation and theater:
$this->assertProfileUnder('checkout', 250); // ms, fails the pipeline
Feed it the same Profiler wrapper so the assertion and the log record can never diverge.
7. Coexist with Debugbar/Telescope. Debugbar has its own timeline; running both on the same request doubles the emit. Pick one per environment — Debugbar locally, Stopwatch→log channel in staging/CI.
start() on a running event throws LogicException. Re-entrant code, retried jobs, or a middleware registered twice will surface this as a 500. Guard with isStarted() or wrap in try/finally and call reset() between units of work.getDuration() returns integer milliseconds. Sub-millisecond work floors to 0, which will make fast code look free. For micro-measurements read StopwatchPeriod::getDuration() (float ms) off getPeriods() / getLastPeriod().getDuration() on an event that was started but never stopped (or is mid-run) returns 0 or a partial sum. Always read after stop().lap(), stop(), and stopSection() all throw when there's no matching open event/section. Unbalanced instrumentation is easy to introduce with early return statements — try/finally is non-negotiable inside jobs and commands.stopSection(). Don't try to read getSectionEvents() mid-phase. Unnamed sections get generated ids (__section__0); name them.Stopwatch::ROOT (7.2+) instead of the '__root__' literal when pulling the root section's events.getMemory() is a memory_get_usage() delta relative to the event start — a directional signal (this job retains 40 MB), never a precise profiler.microtime(), not monotonic. Millisecond-scale differences are fine; ignore sub-ms deltas after an NTP adjustment.scoped, not singleton. Say it out loud in the PR. A singleton Stopwatch in an Octane worker is a slow memory leak that looks like a framework bug.db.query, db_query, and query:users produce output that can't be joined, and renaming later means rewriting dashboards.toArray() in your wrapper; StopwatchEvent::__toString() gives you a human string only.composer why for the major the framework already pins.How can I help you explore Laravel packages today?