Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Stopwatch Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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
]);

Implementation Patterns

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.

Gotchas and Tips

  • 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().
  • A running event reports only completed periods. 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.
  • Section events are only retrievable after stopSection(). Don't try to read getSectionEvents() mid-phase. Unnamed sections get generated ids (__section__0); name them.
  • Use Stopwatch::ROOT (7.2+) instead of the '__root__' literal when pulling the root section's events.
  • Memory numbers are deltas, not allocations. getMemory() is a memory_get_usage() delta relative to the event start — a directional signal (this job retains 40 MB), never a precise profiler.
  • The clock is 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.
  • Don't instrument loops at 100k iterations — the period array itself becomes the memory problem you were hunting. Lap on chunks, not on rows.
  • Agree the event-naming taxonomy before the second squad instruments. db.query, db_query, and query:users produce output that can't be joined, and renaming later means rewriting dashboards.
  • Extension points are minimal by design — there is no reporter interface and no serializer. Plan for a manual toArray() in your wrapper; StopwatchEvent::__toString() gives you a human string only.
  • Verify the version before you commit. The listed 2026-05-29 release date is future-dated; confirm against Packagist and the GitHub tag list, and check composer why for the major the framework already pins.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package