Benchmarks

Grit vs Laravel

The same public CRUD resource, built twice. Same Postgres, same container limits, same 10,000 rows, same k6 script. Three repetitions, medians reported, zero failed requests on either side. The harness is in the repo so you can disagree with it in public.

Results

ScenarioGritLaravel 13Ratio
show
GET /products/:id
4,722 req/s
8.4 ms median · 21.8 ms p95
267% CPU
113 req/s
136.3 ms median · 230.1 ms p95
412% CPU
41.8×
true ceiling
write
POST /products
2,425 req/s
18.2 ms median · 39.9 ms p95
291% CPU
96 req/s
119.3 ms median · 306.7 ms p95
419% CPU
25.4×
true ceiling
list
GET /products?page=N
821 req/s
52.2 ms median · 130.1 ms p95
130% CPU
96 req/s
149.5 ms median · 252.3 ms p95
415% CPU
8.6×
Grit is a floor
mixed
85% list / 10% show / 5% write
748 req/s
58.2 ms median · 148.4 ms p95
126% CPU
95 req/s
119.5 ms median · 226.5 ms p95
412% CPU
7.9×
Grit is a floor

50 concurrent users, 30 seconds per run, 4 CPUs and 2 GB per container. Both sides were given 8 CPUs of Postgres to share — deliberately more than either app gets.

What the CPU column is for

A throughput number only means something when you know what stopped it. In every run Laravel was the thing that saturated — it sat at 412–419% of its 400% allowance, which makes those figures its genuine ceiling on this hardware.

Grit's two read-heavy rows are different. On list and mixed Grit used about 130% while Postgres sat near 850%: the database ran out first, not the framework. Those rows are a floor. Given a larger database Grit would go higher, and the real gap is wider than 8.6× and 7.9× suggest.

The list endpoint runs a COUNT(*) over the whole table on every request, on both sides. That is a fair comparison — both frameworks do the same work — but at 50 concurrent users it is mostly a measurement of Postgres. If you want the cleanest read of framework overhead, look at show: one indexed lookup, one JSON encode, nothing else in the way.

Two bugs this found in Grit

The first draft of these numbers was much worse, and the reason was Grit's fault both times. Building a benchmark you intend to publish is an unusually good way to find your own defaults are wrong.

Connection-pool churn (fixed in v3.131.0)

The scaffold shipped SetMaxIdleConns(10) alongside SetMaxOpenConns(100). Past ten concurrent requests, a connection handed back to a full idle pool is closed — and the next request makes Postgres fork a new backend. Under load that is a connection storm, and it shows up as database CPU rather than as anything you would think to look for in the application.

Single-row reads went from ~810 to ~2,720 req/s on the one-line change. Idle now defaults to Open, tunable via DB_MAX_OPEN_CONNS and DB_MAX_IDLE_CONNS.

REDIS_URL= did not disable Redis (fixed in v3.132.0)

getEnv treated an empty value as unset and returned the default, so the asynq worker started anyway, failed to dial, and retried in a tight loop — burning CPU on reconnects with nothing in the logs but dial errors. Setting it empty now genuinely turns cache, jobs, worker and cron off, and says so once at boot.

How the comparison is kept fair

  • Laravel runs in production shape — nginx + php-fpm with 32 workers, opcache on with tracing JIT, APP_DEBUG=false, and artisan optimize. Not artisan serve, which is a single-threaded dev server and would make the whole thing meaningless.
  • Not Octane. Octane keeps the framework booted between requests and is considerably faster, but it is opt-in and not what most Laravel apps run. Benchmarking it and calling it “Laravel” would be dishonest.
  • The controller matches Grit's generated handler — same page size and cap, same searchable columns, same sortable allow-list, same {data, meta} envelope, same version bump on update. Plain Eloquent rather than API Resources, because Resources would add a layer Grit's handler has no equivalent of.
  • Identical schemas, down to the index on deleted_at. A benchmark where one side has an index the other lacks measures the index.
  • No auth on either side. With a token in play, part of what you measure is JWT parsing rather than the request path.
  • One app runs at a time, the other stopped rather than idle, and a warm-up run is discarded so nobody pays for the other's cold start.
  • Medians of three, never the best run. Publishing a best run is how a benchmark becomes something nobody else can reproduce.

Two ways this benchmark lied before it was fixed

Both are worth knowing if you build one of these yourself, because neither announces itself — you get plausible numbers that are simply wrong.

The write scenario poisoned the read ones

Inserts persist. So list was running COUNT(*) against a table that grew for the whole session — 345,680 rows on the Grit side against 30,255 on Laravel's, because Grit writes faster and therefore polluted its own table harder, then paid for it on every read. Grit's list measured 20 req/s that way. With a reset before every run it measures 821. The harness now truncates and reloads the same 10,000 rows before each run.

k6 must run inside the container network

On Docker Desktop for Windows a published port goes through a userland proxy, and the proxy hits its ceiling before either framework does. Measured through it: 432 req/s with a 3.94 ms floor. Container-to-container, same test: 740 req/s at 1.11 ms. Everything on this page is container-to-container.

Reproduce it

# one Postgres, two databases, identical seed
docker compose up -d postgres
docker compose exec -T postgres psql -U bench -d bench \
-c "CREATE DATABASE bench_grit;" -c "CREATE DATABASE bench_laravel;"
(cd grit-bench && grit migrate)
docker compose up -d laravel
docker compose exec -T laravel php artisan migrate --force
docker compose exec -T laravel php artisan optimize
for db in bench_grit bench_laravel; do
docker compose exec -T postgres psql -q -U bench -d $db < seed/products.sql
done
./final.sh # 3 reps x 4 scenarios x 2 apps, one app at a time
python aggregate.py # medians, plus which rows are DB-bound

aggregate.py prints the app and database CPU for every row and labels each scenario app-bound or DB-bound. If you only take one thing from this page, take that habit: a throughput number without the CPU beside it is a number you cannot interpret.

What this does not tell you

  • It is one machine. A 14-core Windows box running Docker Desktop, with other containers alive on it. Ratios travel better than absolute numbers; run it yourself on the hardware you actually deploy to.
  • Four endpoints is not an application. Real apps have N+1 queries, cache layers, queue workers and third-party calls, and those usually dominate long before framework overhead does.
  • Throughput is rarely why you pick a framework. Laravel has an ecosystem two decades deep. If 96 req/s per 4 CPUs is comfortably above what you need — and for a great many applications it is — this page is not an argument for switching.
  • The list and mixed rows are floors, not ceilings. Stated again because it is the easiest thing on this page to quote wrongly.