Benchmark methodology
GritvsLaravel

Grit vs Laravel

Laravel is the framework Grit is most often compared to, and the one with the strongest claim to being the sensible default for a CRUD API. This measures Laravel 13 the way it is actually deployed: nginx and php-fpm, opcache with a tracing JIT, and the production caches warmed.

PHP 8.4, Laravel 13, nginx + php-fpm, OPcache + JIT, Eloquent · v13 + Eloquent

What you should end up with

ScenarioGritLaravelRatio
show
GET /api/v1/products/:id
7,167 req/s
5.2 ms · neither saturated
275 req/s
68.8 ms · its own ceiling
26.06×
write
POST /api/v1/products
4,497 req/s
8.0 ms · neither saturated
318 req/s
23.9 ms · its own ceiling
14.14×
list
GET /api/v1/products?page=N&page_size=20
1,130 req/s
37.6 ms · database-bound
220 req/s
71.7 ms · its own ceiling
5.14×
mixed
a weighted blend of the three above
1,180 req/s
34.7 ms · database-bound
210 req/s
78.7 ms · its own ceiling
5.62×

Both figures come from the same run, minutes apart. Your absolute numbers will differ (different CPU, different disk, different background load) and so will ours: Grit's single-row read measured 4,536 req/s in the Bun pair and 8,509 in the Express pair from an identical binary, as hours of write scenarios accumulated in Postgres. The ratio is what should survive. If it does not, something in the setup differs and the steps below are where to look.

How Laravel is kept from being handicapped

A benchmark is only worth publishing if the loser was given every reasonable advantage. These are the specific decisions made for this framework, and each one is worth saying out loud on camera.

  • nginx + php-fpm with 32 workers, not `artisan serve`: that is a single-threaded dev server and benchmarking it would be a strawman.
  • opcache on with tracing JIT and validate_timestamps off. Without opcache PHP recompiles every file on every request, and that is not a number anyone would ship.
  • composer install --no-dev, so laravel/pail, collision, phpunit, mockery, faker and pint stay out of the autoloader and out of package discovery. This matters more than it sounds: shipping them cost about 20 ms of bootstrap on every single request.
  • An authoritative, optimised classmap generated in the image with the application code present: not in a stage holding only composer.json, which produces a classmap containing no App classes at all.
  • Persistent PDO connections. Every other framework here holds a pool open; without PDO::ATTR_PERSISTENT, php-fpm opens a fresh Postgres connection on every request and Laravel alone pays the TCP handshake, auth and backend fork.
  • sslmode=disable, the same as every other app in the comparison. Laravel’s default of `prefer` makes PDO attempt an SSL handshake on each connection and fall back, which nobody else was paying for.
  • APP_DEBUG=false, APP_ENV=production, and `artisan optimize` run at container start: after the environment exists, so the cached config holds the real database settings rather than build-time defaults.
  • Deliberately 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 the result "Laravel" would be dishonest.
  • The controller is plain Eloquent, not API Resources. Resources would add a transformation layer Grit’s handler has no equivalent of, and that cost would look like a Laravel tax when it is really a difference in what the two are doing.

Reproduce it, start to finish

  1. 1

    Install the two tools you need

    Docker runs every app and the database. k6 generates the load. Nothing else is required: you do not need Go, PHP, Python, Node or Bun installed locally, because every framework builds inside a container.

    # macOS
    brew install k6
    # Windows
    winget install k6 --source winget
    # Linux
    sudo gpg -k && sudo gpg --no-default-keyring \
    --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
    --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
    echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" \
    | sudo tee /etc/apt/sources.list.d/k6.list
    sudo apt-get update && sudo apt-get install k6
    k6 version
    docker --version
  2. 2

    Clone the harness

    Everything below lives in the benchmarks directory of the Grit repository: the compose file, the k6 script, the seed data, and each framework’s application.

    git clone https://github.com/MUKE-coder/grit.git
    cd grit/benchmarks
  3. 3

    Start Postgres and create the databases

    One Postgres instance is shared by every framework, tuned once, with 8 CPUs: deliberately more than any application gets, so the database is never what gives out first. Each framework gets its own database so their schemas cannot collide.

    docker compose up -d postgres
    for db in bench_grit bench_encore bench_bun bench_express bench_nextjs bench_laravel bench_django; do
    docker compose exec -T postgres psql -q -U bench -d bench -c "CREATE DATABASE $db;"
    done
  4. 4

    Create the table and load identical rows

    This is the single most important step for a fair result. Every framework gets the same table, created from the same SQL file, holding the same 10,000 rows with the same UUIDs. Letting each ORM run its own migration would mean comparing schemas, not frameworks: Eloquent, Django and GORM disagree about integer widths, timestamp precision and which columns get indexed.

    for db in bench_grit bench_encore bench_bun bench_express bench_nextjs bench_laravel bench_django; do
    docker compose exec -T postgres psql -q -U bench -d $db < seed/schema.sql
    docker compose exec -T postgres psql -q -U bench -d $db < seed/products.sql
    done
    # every one should print 10000
    for db in bench_grit bench_encore bench_bun bench_express bench_nextjs bench_laravel bench_django; do
    docker compose exec -T postgres psql -tA -U bench -d $db -c "SELECT count(*) FROM products;"
    done

    If any of these prints something other than 10000, stop and fix it. A benchmark where one side has more rows than another is measuring the row count.

  5. 5

    Look at the Laravel controller

    Open laravel-bench/app/Http/Controllers/ProductController.php. Every choice in it exists to match Grit’s generated handler: the same default page size of 20 and cap of 100, the same three searchable columns, the same sortable allow-list, the same {data, meta} envelope, and the same version bump on update.

    cat laravel-bench/app/Http/Controllers/ProductController.php
  6. 6

    Build and start it

    The Dockerfile installs nginx and php-fpm, enables opcache with a JIT, sets a static pool of 32 workers sized against the container’s 4 CPUs, and builds vendor/ in its own composer stage with --no-dev. The entrypoint warms the config, route, view and event caches at start rather than at build time, because the database environment does not exist while the image is being built and a build-time config cache would freeze the wrong settings in.

    docker compose build laravel
    docker compose up -d laravel
    # the entrypoint has already run artisan optimize — this is the proof
    docker compose exec -T laravel ls bootstrap/cache/
    # expect: config.php events.php packages.php routes-v7.php services.php

    Skipping artisan optimize costs Laravel roughly a third of its throughput. It now runs automatically in the entrypoint. The first version of this benchmark relied on a manual step the harness never actually performed, so Laravel was measured without it: show the ls output rather than trusting that it happened.

  7. 7

    Verify the production vendor and the connection settings

    These are the three faults that made the first published Laravel figures too low. Worth showing on camera, because none of them announces itself: you get a slow framework and no indication why.

    # 1. no dev packages discovered — expect only tinker, carbon, termwind
    docker compose exec -T laravel php -r 'echo implode(" ", array_keys(require "bootstrap/cache/packages.php")), PHP_EOL;'
    # 2. bootstrap cost on a route that touches no database — expect ~7 ms, not ~27 ms
    docker compose exec -T laravel sh -c 'for i in 1 2 3; do curl -s -o /dev/null -w "%{time_total}
    " http://127.0.0.1:8080/up; done'
    # 3. connections are held open rather than re-opened per request
    docker compose exec -T postgres psql -U bench -d postgres -tAc "select count(*) from pg_stat_activity where datname = 'bench_laravel';"

    If step 2 shows around 27 ms, dev dependencies are still in the autoloader and every number you go on to measure will be about 20 ms per request too slow.

  8. 8

    Confirm opcache is really on

    Worth checking on camera, because it is the single biggest lever on PHP performance and it is easy to assume rather than verify.

    docker compose exec -T laravel php -i | grep -E "opcache.enable |opcache.jit "
  9. 9

    Check every framework is on an ORM, not raw SQL

    This is the fairness decision that matters most after the shared schema. Grit’s generated handlers use GORM and you cannot swap that out, so measuring any framework against hand-written SQL compares an ORM to no ORM, which flatters that framework for a reason that has nothing to do with it. Express on raw pg measured 2,000 req/s on writes; the same Express on Prisma measured 773. Same framework, same machine, same test.

    # Grit GORM apps/api/internal/models/product.go
    # Laravel Eloquent laravel-bench/app/Models/Product.php
    # Django Django ORM django-bench/products/models.py
    # Express Prisma express-bench/prisma/schema.prisma
    # Next.js Prisma nextjs-bench/prisma/schema.prisma
    # Bun Drizzle bun-bench/schema.ts
    # Encore Drizzle encore-bench/products/schema.ts
    grep -rl "prisma|drizzle|Eloquent|models.Model" express-bench nextjs-bench bun-bench encore-bench django-bench laravel-bench 2>/dev/null | head

    If you swap any of these for raw SQL the numbers move enough to change the conclusion. That is the single easiest way to make this benchmark say whatever you want it to say.

  10. 10

    Prove the two apps return the same bytes

    Before measuring anything, confirm the framework you are testing returns exactly what Grit returns for the same record. If the payloads differ, say a missing field or a price as a string instead of a number, then the two are doing different amounts of work and the comparison is void.

    ID=$(python -c "import json;print(json.load(open('seed/ids.json'))[0])")
    for app in grit laravel; do
    echo "--- $app"
    docker run --rm --network benchmarks_default curlimages/curl -s \
    "http://$app:8080/api/v1/products/$ID"
    echo
    done

    This is the step most benchmarks skip, and it is the one that decides whether the rest means anything. Show it on camera.

  11. 11

    Run the benchmark

    Three repetitions of four scenarios, one application at a time with the others stopped. Each run resets the dataset first, discards a warm-up, and samples container CPU while the load is actually running.

    APPS="grit laravel" REPS=3 ./final.sh
    python aggregate.py

Reading your own results

Laravel lands around 100–175 req/s across the four scenarios and saturates its own container every time, sitting at 428–437% of its 400% allowance while Postgres stays between 47% and 193%. That combination is what makes these Laravel’s genuine ceilings on this hardware rather than an artefact of something else running out first. If you see figures nearer 90–115, check the three faults listed above: dev dependencies in the autoloader, no persistent connections, and sslmode=prefer. Together they were costing Laravel roughly a third of its throughput, and the first version of this benchmark published the lower numbers before they were found.

aggregate.py prints the app container's CPU and Postgres's CPU next to every row. That pair is what tells you whether a number is a real ceiling. If the app is pinned near 400% then you are seeing its limit. If the app is idling while Postgres is near 800%, the database gave out first. That row is a floor, the framework would go faster on a bigger database, and quoting it as “X does N req/s” overstates what you measured.

The other guides