Search by

olgunozoktas / laravel-page-performance

olgunozoktas

Measure what every Laravel page actually costs, and say what to fix. Warms the process, reports the MEDIAN of N in-process requests, and gives each page a diagnosis — repeated queries with the file:line that ran them, database share, response bytes, and per-component Livewire timing and snapshot pay

Package info

github.com/olgunozoktas/laravel-page-performance

pkg:composer/olgunozoktas/laravel-page-performance

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.2 2026-11-06 15:47 UTC

This package is auto-updated.

Last update: 2026-11-06 22:48:32 UTC


README

Measure what every page actually costs, and say what to fix.

php artisan perf:pages walks every parameterless GET route, warms the process, measures N in-process requests, and prints a table ordered worst-first where every row carries a diagnosis rather than a number.

+-----------------------------+------+--------+------+------+------+----+-----+------+------+-------------------------------------------+
| page                        | ms   | spread | cold | db   | lw   | q  | dup | snap | html | diagnosis                                 |
+-----------------------------+------+--------+------+------+------+----+-----+------+------+-------------------------------------------+
| board.home                  | 68.5 | 2%     | 71   | 34.9 | 24.4 | 18 | 2   | 727  | 260K | repeated-query · db-bound · payload-heavy |
| board.directory             | 32.6 | 11%    | 39   | 13.5 | 12.5 | 8  | 1   | 277  | 153K | repeated-query · payload-heavy            |
| board.rules                 | 15.5 | 4%     | 16   | 6.9  | 3.0  | 5  | 1   | 273  | 69K  | repeated-query                            |
+-----------------------------+------+--------+------+------+------+----+-----+------+------+-------------------------------------------+

board.home /   repeated-query · db-bound · payload-heavy
    x2   select count(*) as "aggregate" from "listings" where "status" = ? and …
         app/Boards/PublicPages/BoardHome.php:88

A table of milliseconds tells you which page is slowest. It does not tell you what to open. Every finding here ends in a file and a line.

Those route names are real. board.home, board.directory and board.rules are pages of SeeRanks, the application this package was written for and is measured against. Every number in this README — the 150.4 ms cold request, the 287,831 bytes that arrive as 32,514, the 77 findings that group to 8 — came from a sweep of that codebase, not from a benchmark written to make a point.

Install

From Packagist:

composer require --dev olgunozoktas/laravel-page-performance
php artisan vendor:publish --tag=page-performance-config

It is a development tool: install it with --dev. perf:pages refuses to run in production.

Livewire is optional. Without it you get every page-level number; with it you also get per-component render timing and snapshot payload bytes.

What it measures

Column Meaning
ms Median of the timed runs. Never the mean — one GC pause ruins a mean and nobody reading it can see that it did.
spread How far the runs disagreed. Above ~25% the position is not trustworthy, and the report says so.
cold The discarded first run. Kept and shown, never averaged in.
db Database time.
lw Livewire time, children counted once. Empty when Livewire is absent or app.debug is false.
q / dup Queries, and how many executions a fix would delete.
snap Livewire snapshot bytes — sent up and back on every interaction.
html Response bytes, uncompressed.
wire What the visitor downloads. Estimated gzip size — the number payload-heavy judges.

The diagnosis vocabulary

Every label has a stated rule, so it can be argued with.

Label Rule
repeated-query identical SQL and bindings ran ≥2×
n-plus-one identical normalised SQL, ≥5 distinct binding sets
db-bound database time ≥ 50% of the request and ≥ 10 ms
livewire-bound Livewire ≥ 40% of the request and ≥ 10 ms
child-heavy one child ≥ 25% of its parent's render
payload-heavy response ≥ 100 KB compressed
snapshot-heavy snapshot ≥ 4 KB, or ≥ 3% of the page
vendor-bound outbound HTTP was made inside the render
uncacheable no-store on a page that mounts a component — and it says whether a per-session token makes the page uncacheable anyway
query-heavy over the page's query budget
unbudgeted no budget and no stated reason for not having one
ok none of the above — printed with its numbers, because a healthy row and an unmeasured row must not look the same

Defects and characteristics are separate, and only one of them should reach zero

Under the summary come two tables, and the difference between them is the point.

Defects are things that are wrong and that you can fix: a repeated query, an N+1, an outbound call inside a render, a page over its budget.

Characteristics are where a request spends itself — db-bound, livewire-bound, a page that cannot be shared-cached because it carries a per-session token, a row whose spread says the measurement is not trustworthy. They are worth printing and they are not defects.

Mixing them makes the number unusable. On one real board, every duplicate query was fixed and the report still showed five "findings", none of which anybody should act on — and a count that cannot reach zero is a count people stop reading.

One row per defect, not a paragraph per page:

  Findings .............................................. 52, worst page first
+----+----------------------+-------------------+------------------------------------------+---------------------------------+
| #  | page                 | finding           | evidence                                 | where                           |
+----+----------------------+-------------------+------------------------------------------+---------------------------------+
| 1  | board.home           | repeated-query    | x2 select count(*) from "listings" …     | app/Boards/ReadsBoard.php:49    |
| 3  | board.home           | livewire-bound    | 23.7 ms of 40.5 ms in Livewire           | —                               |
| 4  | board.home           | payload-heavy     | 268K of HTML                             | —                               |
| 7  | board.similar-boards | noisy-measurement | 41% spread — position not trustworthy    | —                               |
+----+----------------------+-------------------+------------------------------------------+---------------------------------+

The where column is an OSC 8 hyperlink. Click it and the file opens at the line.

// config/page-performance.php
'editor' => env('PAGE_PERFORMANCE_EDITOR', 'phpstorm'),

phpstorm · idea · vscode · cursor · sublime · textmate · zed · file. An empty value prints plain text, and so does an editor the package does not recognise — emitting a URL scheme nothing will answer is worse than emitting none.

It degrades whenever the output is not a terminal. Piped into a file or a pager, those escapes would sit in the output as control characters and stop a grep matching the very paths the column exists to hand over. Both directions are tested.

Three things it gets right that a naive sweep does not

1. The cold-process trap. The first request handled in a PHP process costs a fixed amount regardless of which page it is. Measured on a real application:

Order Page Wall
1st in process / 150.4 ms
1st in process /products 92.0 ms
3rd–6th / 41–53 ms

A sweep without a warmup charges that to whichever page ran first and presents the result as a ranking by cost. This warms first, discards the cold run, and shows it in its own column.

2. Milliseconds are a column, never the sort key. Rows order by avoidable queries, then database time, then bytes, and only then the clock. Deterministic evidence decides the order; time is the tiebreak of last resort.

3. One defect is one row. A finding carried by 27 pages is printed once, with 27 pages in the page column — not 27 times. A real sweep produced 77 findings of which 28 were the same table-existence check from the same line; grouped, the same sweep reports 8. A report you have to de-duplicate by eye is a report that gets skimmed.

4. Ratio labels have an absolute floor. A 1.8 ms page is not db-bound because 0.9 ms of it was a settings lookup. Below 10 ms a share is arithmetic, not a finding — and noisy-measurement needs both a wide spread and a 15 ms absolute gap, because 340% of 2.5 ms is jitter.

5. Page weight is judged COMPRESSED, because that is what a visitor pays. Measured on a real board home page: 287,831 bytes of HTML arrive as 32,514 over the wire, 89% smaller. The things that make server-rendered HTML large — repeated Tailwind classes, repeated inline SVG, framework comments — are exactly what a compressor removes. Judging the uncompressed number sends people to refactor a shared icon component for about two hundred real bytes. Both figures are reported so the ratio is visible.

6. in (?, ?, ?) collapses to in (?) before anything is counted. A chunked or paginated loop emits the same statement with a different number of placeholders each pass. Unnormalised those are separate shapes and the loop is reported as nothing at all.

The gate half

Timings are reported. Only deterministic counters may be asserted:

Bucket Gate?
query count, avoidable executions, response bytes, component count yes
wall time, database time, memory never

A timing assertion in a parallel suite measures the other workers. One real suite reported 232, then 478, then 11 failures across three runs of identical code. A gate that fails for reasons nobody can act on is one people learn to ignore.

Budgets live in your published config, measured against your fixture:

'budgets' => [
    'board.home' => [
        'queries' => 20, 'duplicates' => 2, 'bytes' => 199_000, 'components' => 3,
        'measured' => '2026-08-26',
        'why' => '17 queries and 155 KB over the seeded board. A ceiling with headroom,
                  not an exact match. Duplicates is 2 and that is the remaining debt.',
    ],
],

why and measured are not decoration. A number you can raise without writing a sentence beside it is a number that gets raised without being read — which is how a budget stops being one. The ratchet: a budget may fall in any commit; raising one means rewriting the reason.

There is no --update-baseline. A budget a machine can rewrite is not a budget. Re-measure with perf:pages --json and copy the number in by hand.

Assert them in your own test — Budgets::all(), Budgets::covers() and Budgets::durationKeys() are the reader.

Two costs most tools miss

Outbound HTTP inside a render. A query is as slow as your database; a third party's API is as slow as somebody else's afternoon. vendor-bound names the host and the count. It sees Laravel's own HTTP client — a raw curl handle or a vendor SDK with its own Guzzle goes unseen, so the label firing is proof and its silence is not.

Pages that no cache may hold. Livewire's SupportDisablingBackButtonCache sets Cache-Control: no-store on every page that mounts a component. That is right behind a login and quietly expensive on a public one: a landing page you wanted a CDN to hold is uncacheable because it carries a single component.

The finding names the real blocker, not just the cause — and that distinction is a security one. Saying only "Livewire set no-store" invites removing the header and putting the page behind a CDN. Measured on a real board home page, that would have served one visitor's CSRF token, session cookie and Livewire snapshot checksum to the next visitor. So the row says whether the page carries per-session state, and only a page carrying none is worth questioning.

Honest limits

  • The per-component phase breakdown is local-only, permanently. Livewire gates its own profile event on config('app.debug') at every trigger site. Production runs APP_DEBUG=false and must. Snapshot bytes are the Livewire metric that survives production — they are read from the response HTML and need no instrumentation at all.
  • It measures your local database, which is the point: on an empty database a listing page renders no rows, and that is a number about software you do not ship. It refuses to run against production.
  • It never writes. GET routes only; mutating verbs are excluded structurally, not by a list somebody maintains.
  • Times depend on opcache in the CLI. PHP's CLI default is opcache.enable_cli=0, and then every render compiles the views again. Measured on a Livewire app (2026-11-07): one component took 15.9 ms with it off and 6.7 ms with it on, and only the run with it off reported child-heavy. When it is off, the report prints a WARN line before the table (and opcache_warning in --json). Sizes and query counts do not change. For a time finding, run php -d opcache.enable_cli=1 artisan perf:pages … on a quiet machine.
  • Times depend on a quiet machine. The command reads the 1-minute load and the core count before the first page. When the load is over the core count, it prints a WARN line before the table (and load_warning in --json). Measured on a Livewire app (2026-11-07): every run of one evening started at a load of 22 to 49 on a 12-core Mac, and the same page spread 47% to 175% between runs. Sizes and query counts do not change.
  • Parameterised routes are excluded and counted. A guessed id answers 404 in about 3 ms and would rank as the fastest page in the application.

Commands

php artisan perf:pages                     # everything
php artisan perf:pages --only=board        # substring filter on the route name
php artisan perf:pages --repeat=9          # more iterations
php artisan perf:pages --shuffle           # if the ranking changes, it is noise
php artisan perf:pages --as=me@example.com # measure authenticated pages
php artisan perf:pages --json

Exit codes: 0 measured · 2 nothing could be measured, which is not a pass · non-zero on bad usage.

A sweep measures every page in one process. The command raises PHP's memory limit to page-performance.memory_limit (default 1G, PAGE_PERFORMANCE_MEMORY_LIMIT) before it starts, and never lowers it. Measured on a Livewire directory app (2026-11-06): 194 pages ran out of the CLI default of 128M. Set it to '' to leave the limit as it is.

The agent skill

The repository ships a skill at .claude/skills/page-performance/SKILL.md. Copy it into a project so an agent working there knows how to read the output, which findings are safe to act on, and — the part that matters — the two that are dangerous to "fix":

mkdir -p .claude/skills
cp -R vendor/olgunozoktas/laravel-page-performance/.claude/skills/page-performance .claude/skills/

It carries the measured lessons rather than the API: that a navigation memo saved 0.002 ms, that a 280 KB page ships as 30 KB, that removing no-store from a page with a session token publishes one visitor's CSRF token to the next, and that Livewire::test() on a #[Lazy] component renders the placeholder — so an authorization test written the obvious way asserts 403, gets 200, and protects nothing.

Tests

composer test   # pint --test, phpstan level max, pest

Every detector is proved able to fire and able to stay quiet — including a page with nothing wrong, which must produce exactly ok. A detector that reports nothing everywhere cannot be told from a broken one.

Where this came from, and what else runs on it

This package exists because one Laravel application needed it. That application is SeeRanks — a paid ranking board for developer products, being rebuilt on Laravel 13 + Livewire 4. It is where the cold-process trap was measured, where the no-store finding was traced to a real CSRF token, and where a 77-finding sweep turned out to be 8 defects printed 77 times.

Two other things by the same author, both free:

FindUtils Nearly 400 free online tools — converters, formatters, generators, calculators. The work happens in the browser: no account, and files are not uploaded.
Emoji Favicons Turn any emoji into a favicon, with a documented API. One <link> tag and a site has an icon.

Neither is a Laravel application, so neither is measured by this package. They are here because the same person maintains all four, and because a tool is easier to trust when you can see what else its author ships.

Licence

MIT.