Benchmarks¶
Interactive
See the numbers below rendered as live charts in the Benchmark Dashboard, and try the engine's API in the Live Playground (real PHP in your browser via WebAssembly).
The engine ships with a dependency-free micro-benchmark that exercises each phase in isolation (parse, build, validate, execute) plus list throughput and DataLoader batching:
php benchmarks/run.php # or: composer bench — human-readable table
php benchmarks/run.php --json=out.json # machine-readable JSON (feeds the dashboard)
It reports the median over many iterations (more stable than the mean under GC jitter) and verifies the DataLoader coalesces N loads into a single batch.
Reference numbers¶
Indicative results on an Apple Silicon laptop, PHP 8.4 (no JIT). Numbers are machine-specific — run the suite on your own hardware for absolute figures; the shape (per-phase cost, scaling) is what matters.
| Scenario | Median | Throughput |
|---|---|---|
| parse: small query | ~6 µs | ~165k/s |
| parse: nested query | ~25 µs | ~40k/s |
| build: schema from SDL | ~100 µs | ~10k/s |
| validate: nested query | ~7 µs | ~142k/s |
| execute: flat field | ~6 µs | ~171k/s |
| execute: list of 100 | ~0.64 ms | ~1,560/s |
| execute: list of 1000 | ~6.4 ms | ~157/s |
| execute: 500 nested + DataLoader | ~7.8 ms | ~130/s |
| full: parse+validate+execute (100) | ~0.68 ms | ~1,470/s |
Takeaways
- Parse, validate and small executions are in the microsecond range — the engine is not a bottleneck for typical requests.
- A realistic page (10–100 objects) resolves in single-digit milliseconds.
- The DataLoader turns an N+1 relation into one batched load (verified by the harness), which is the difference that matters against a real database.
Scaling & the executor rewrite¶
Two rounds of profiling fixed list execution:
- O(N²) microtask drain.
SyncPromise::runQueue()drained its queue witharray_shift(), which re-indexes the whole array on every call. A moving index (plus dropping anO(n log n)ksortinSyncPromise::all()) restored linear queue draining. - Per-field promise allocation. The executor allocated a promise + closure + microtask for every field, even fully synchronous ones. It now completes synchronous fields/lists/objects inline (returning plain values) and only allocates promises when a resolver actually defers (DataLoader). This is the graphql-js hybrid model.
Result: per-item cost is now constant (~3.8 µs/item) instead of growing with list size, a 1 000-item list dropped from ~56 ms to ~6.4 ms, and peak memory for the benchmark fell from ~56 MB to ~16 MB. DataLoader batching is unchanged — deferred resolvers still take the async path and coalesce into one load.
Sustained load & memory stability¶
PHP has no in-process threads, so "concurrency" for a request/response engine is really
sustained throughput plus no per-request leak. composer bench runs 20 000 full
parse + validate + execute cycles of a 100-object query and reports both:
- throughput: ~250 req/s for that (deliberately heavy) 100-object operation on one worker — real endpoints paginate and serve far smaller payloads much faster;
- heap growth: ≈0 MB across the run — memory is flat, so there is no per-request leak (the promise graph is released each cycle).
Versus webonyx/graphql-php¶
webonyx/graphql-php is the engine behind both Lighthouse and rebing/graphql-laravel,
so an engine-to-engine comparison is the fair way to read "vs Lighthouse" — it isolates
the executor from the Laravel HTTP, directive and Eloquent layers a full Lighthouse
request adds. Run it with:
composer require --dev webonyx/graphql-php
php benchmarks/vs-webonyx.php # or: composer bench:vs
Indicative results (Apple Silicon, PHP 8.4, identical SDL + in-memory data):
| Scenario | this package | webonyx | verdict |
|---|---|---|---|
| parse: list query | ~21 µs | ~33 µs | 1.6× faster |
| validate: list query | ~6 µs | ~208 µs | ~34× faster |
| execute: flat field | ~2 µs | ~94 µs | ~41× faster |
| execute: list of 100 | ~0.65 ms | ~1.4 ms | 2.1× faster |
| execute: list of 1000 | ~6.4 ms | ~11.5 ms | 1.8× faster |
Honest reading:
- After the executor rewrite (see above), this engine is faster across every scenario measured — dramatically so on fixed-overhead work (parse/validate/small execution) and comfortably on large lists too.
- Both engines batch with a DataLoader; the difference here is raw execution, resolving the identical query from the identical in-memory data.
- Caveat: validation cost depends on rule coverage; webonyx runs a larger standard rule set, so part of that gap reflects breadth, not just speed.
Eloquent directive layer (end-to-end)¶
Measures the directive/filter stack itself — parse + validate + directive resolution + Eloquent over sqlite (200 rows):
./vendor/bin/phpunit tests/Benchmark/EloquentDirectiveBench.php
| Scenario | Median |
|---|---|
@all (200 rows) |
~2.7 ms |
@all + @eq (1 match) |
~0.12 ms |
@paginate + @eq |
~0.19 ms |
The filter directives add negligible overhead — @eq is faster here because it
narrows the query to a single row instead of materialising all 200. Time tracks the
number of rows resolved, not the directive machinery.
Versus Lighthouse (end-to-end)¶
The engine numbers above isolate the executor. This measures the full stack —
Laravel + the @all directive + Eloquent over the same sqlite table — through each
package's GraphQL execution service (everything an HTTP request does bar the identical
kernel/routing overhead):
composer require --dev nuwave/lighthouse
./vendor/bin/phpunit tests/Benchmark/LighthouseEndToEndBench.php
Same SDL parsed by both engines, 200 rows, sqlite (Apple Silicon, PHP 8.4):
| Scenario | laravel-graphql | lighthouse | verdict |
|---|---|---|---|
@all (200 rows) |
~3.4 ms | ~5.1 ms | 1.5× faster |
@all + @eq (1 match) |
~0.12 ms | ~0.45 ms | 3.7× faster |
@paginate + @eq |
~0.19 ms | ~0.73 ms | 3.7× faster |
Honest reading: end-to-end — full Laravel, the same directives and Eloquent over the same sqlite table — this package resolves the plain list, the filtered query and the paginated query faster than Lighthouse. The shared DB/Eloquent cost is fixed for both, so the win comes from the lower engine overhead; it widens on the filtered/paginated queries, where less data is materialised and the engine share of the time grows. Both resolve the identical query from the identical model — the difference is engine, not features.
Benchmarks are a regression guard, not a marketing number. If you change the executor or promise machinery, run
composer benchbefore and after.