Skip to content

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:

  1. O(N²) microtask drain. SyncPromise::runQueue() drained its queue with array_shift(), which re-indexes the whole array on every call. A moving index (plus dropping an O(n log n) ksort in SyncPromise::all()) restored linear queue draining.
  2. 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 bench before and after.