sixty

for PHP and Laravel apps

No background thread. Nothing lost.

A PHP worker cannot run a timer, and everything it learned dies with the request. That is the problem this agent had to solve before it could measure anything: windows are pooled in shared memory and merged — losslessly, or the percentiles would be fiction — and the flush happens after the response has already gone, so a collector having a bad day costs your reader nothing.

What it measures

Every request, and the methods under it
Laravel and Symfony wire the request span themselves. Sixty::trace('OrdersQuery#forUser', fn () => …) marks the code between the controller and the database — a line rather than a decorator, because PHP has no build step to rewrite functions and no hook that fires when a method is defined.
Every query, without replacing your connection
PDO is instrumented by setting the statement class on the connection that already exists. A PDO subclass would have to open its own, silently doubling every deployment's connection count — which is the kind of thing a monitoring tool must never do.
Laravel and Doctrine, on any driver
Laravel connections are picked up at ConnectionEstablished, with rows from rowCount(). Doctrine DBAL gets a middleware registered by the bundle, with rows from the result's own count. Both give you the statement shape and the attribution.
MongoDB, including Doctrine ODM
Through the driver's command monitoring. Identity is built from keys only, so no value of yours has a path into it — and cursor round trips are counted, which is the signal that catches a read arriving in three hundred instalments.
What the database did to answer
A generic-plan EXPLAIN on Postgres, cached per statement and issued outside the caller's span, so "this statement stopped using its index" arrives without a parameter of yours ever being bound.

What it finds

Every one of these is compared against the release before it, so the finding names the change rather than the day.

A query that stopped using its index

The finding that names a cause rather than a symptom. It often arrives while everything is still fast, because the table is still small — and becomes an outage some weeks later, when it is not.

An N+1 that was not there last release

One query per request became twenty. Every one of them fast, every one of them indexed, and the only thing that changed is the count.

A query that started reading the whole table

Rows per call went from 30 to 30,000, usually a where that moved out of the query and into PHP during a refactor.

A read that turned into hundreds of network waits

MongoDB answers a cursor in batches. When a result outgrows a batch, one read becomes dozens or hundreds of sequential round trips — identical documents, and nothing moves except the clock.

Your own code getting slower

Time in the method itself rather than in what it called, separated, so you are looking in the right file on the first try.

A query that quietly returns nothing

Succeeded, threw nothing, was not slow, and came back empty where it used to come back with rows.

What lands in your feed

Not a dashboard to read. One card per finding, with the numbers, the release that changed them, and the evidence your coding agent needs to write the fix.

These are the two regressions apps/demo-laravel ships on purpose, against the dataset its seeder creates — about 30,000 orders. You can run it and watch them arrive.

rowsOrdersQuery#forUser
30 rows30,000 rows1000×

The constraint moved out of the query and into PHP during a refactor, so every call loads the whole table. Same answer, same code review, and a few milliseconds on a development database.

since release v2, against v1

N+1OrdersQuery#enrich
1 query20 queries20×

A batched lookup became a per-order one. Each query is fast and indexed, so no request looks wrong — the count is the only thing that moved.

since release v2, against v1

How it installs

Laravel discovers the service provider; Symfony needs the bundle added to config/bundles.php. Both then wire up the request span, the query instrumentation and the flush — there is no initializer to write and nothing to call.

claude mcp add sixty \
  -e SIXTY_API_KEY=sixty_sk_… \
  -e SIXTY_ENDPOINT=https://ingest.sixty.sh \
  -- npx -y @sixty-sh/mcp

# or by hand:
composer require sixty-sh/sixty

SIXTY_API_KEY=sixty_sk_…
SIXTY_SERVICE=checkout-api

# On Laravel, that is the install — the provider is discovered.
# On Symfony, add the bundle to config/bundles.php.

# Anything else:
\Sixty\Sixty::init();
\Sixty\Instrument\Pdo::instrument($pdo);

# Your own code:
\Sixty\Sixty::trace('OrdersQuery#forUser', fn () => ...);

Install ext-apcu as well and the windows from every worker merge into one payload per interval. Without it the agent still works and says so once at startup rather than pretending otherwise — it is simply a great deal more traffic. The release identifier comes from your platform on its own, or from SIXTY_RELEASE.

What it does not do

  • It refuses to enable under Swoole coroutines, and says why. Many requests share one worker there and switch at every I/O boundary, so the current span would credit one request's queries to another request's controller. FPM, CLI, RoadRunner and FrankenPHP are process-per-request and fully supported.
  • Without ext-apcu each request sends its own window. It still works and the agent tells you once at startup — but the lossless cross-worker merge is what makes the percentiles what a single process measuring everything would have reported, and without it you are paying in traffic.
  • PDO::query() and PDO::exec() never reach a statement class, so a connection you construct yourself and call those on needs TracedPdo. prepare() + execute() — every query Laravel and Doctrine issue — is covered.
  • If something else already owns the statement class — a profiler, a debug bar — the agent leaves it alone and measures nothing rather than breaking it. That is the right trade and it is silent, so it is worth knowing it can happen.
  • CPU and waiting are not split, and query plans are Postgres only. MySQL and MongoDB can only explain a query that still has its values in it, and this agent drops values the moment it sees them.

If one of these is why you are here

Find out what your last change did.

Try it!

Building something else? See Node services, Ruby and Rails apps or Python services — those pages are different, and so is what we can see.

Performance monitoring for PHP and Laravel — Postgres, MySQL and MongoDB — Sixty