This project measures the time and memory required to fetch 10 million rows from a PostgreSQL database using three different PHP approaches, in two modes each: streaming (one row at a time) and fetch-all (entire result set loaded into memory at once).
| Container | Language / Framework | Streaming strategy | Fetch-all strategy |
|---|---|---|---|
pdo_benchmark |
Pure PHP + PDO | fetch() — FETCH_ASSOC |
fetchAll() — FETCH_ASSOC |
pdo_benchmark |
Pure PHP + PDO | fetch() — FETCH_OBJ |
fetchAll() — FETCH_OBJ |
pdo_benchmark |
Pure PHP + PDO | — | fetchAll() — FETCH_CLASS |
symfony_benchmark |
Symfony 8 + Doctrine ORM | toIterable() + detach() |
getResult() |
laravel_benchmark |
Laravel 11 + Eloquent | cursor() — LazyCollection |
get() — Collection |
- Database: PostgreSQL 18
- PHP: 8.5-cli-alpine
- Table:
records— 10,000,000 rows, 7 columns (id,name,email,age,score,active,created_at)
| Approach | Mode | Rows Fetched | Time | Peak Memory |
|---|---|---|---|---|
| Pure PDO (FETCH_ASSOC) | fetch-all | 10,000,000 | 7.727 seconds | 5.54 GB |
| Pure PDO (FETCH_CLASS) | fetch-all | 10,000,000 | 6.282 seconds | 3.32 GB |
| Pure PDO (FETCH_OBJ) | fetch-all | 10,000,000 | 9.000 seconds | 6.04 GB |
| Symfony + Doctrine ORM | fetch-all | 10,000,000 | 123.336 seconds | 16.97 GB |
| Laravel + Eloquent | fetch-all | 10,000,000 | 120.495 seconds | 14.54 GB |
| Pure PDO (FETCH_ASSOC) | streaming | 10,000,000 | 4.462 seconds | 2 MB |
| Pure PDO (FETCH_CLASS) | streaming | 10,000,000 | 4.919 seconds | 2 MB |
| Pure PDO (FETCH_OBJ) | streaming | 10,000,000 | 4.407 seconds | 2 MB |
| Symfony + Doctrine ORM | streaming | 10,000,000 | 47.733 seconds | 14 MB |
| Laravel + Eloquent | streaming | 10,000,000 | 91.955 seconds | 28 MB |
Results measured inside Docker containers. Hardware and Docker resource limits affect numbers. Fetch-all benchmarks run with
memory_limit=-1; OOM means the OS killed the process before completion.
Streaming approaches keep memory flat regardless of result set size:
- PDO cursor: ~2 MB constant
- Doctrine ORM with
detach(): ~28 MB (framework bootstrap only) - Eloquent with
cursor(): ~28 MB (framework bootstrap only)
Fetch-all loads the entire result set into memory before iterating:
- PDO
fetchAll()FETCH_ASSOC requires ~5.5 GB for 10 M rows - PDO
fetchAll()FETCH_CLASS requires ~3.3 GB (typed objects are more compact than hash maps) - Doctrine ORM
getResult()is killed by the OS OOM killer before completing - Eloquent
get()requires ~14.5 GB (full model hydration overhead)
- Pure PDO — fastest and lowest memory. No ORM overhead, direct RDBMS cursor.
- Symfony + Doctrine ORM —
toIterable()streams one hydrated entity at a time; the Doctrine UnitOfWork pipeline adds per-row cost.detach()after each row keeps the identity map flat. - Laravel + Eloquent —
cursor()streams one model at a time but fires attribute casts, accessor resolution, and model events on every single row.
- Docker + Docker Compose v2
cd php-database-fetchchmod +x run_benchmarks.sh generate_readme.sh
./run_benchmarks.shThis will:
- Start PostgreSQL
- Populate the
recordstable with 10 million rows - Build the three PHP benchmark containers
- Run all streaming and fetch-all benchmarks in each container
- Regenerate this README with the result table
# PDO — streaming
docker exec pdo_benchmark php /app/fetch.php
# PDO — fetch-all (memory_limit=-1 required for 10 M rows)
docker exec pdo_benchmark php -d memory_limit=-1 /app/fetch_all.php
docker exec pdo_benchmark php -d memory_limit=-1 /app/fetch_class_all.php
# Symfony + Doctrine ORM
docker exec symfony_benchmark php bin/console benchmark:fetch
docker exec symfony_benchmark php -d memory_limit=-1 bin/console benchmark:fetch-all
# Laravel + Eloquent
docker exec laravel_benchmark php artisan benchmark:fetch
docker exec laravel_benchmark php -d memory_limit=-1 artisan benchmark:fetch-alldocker compose down -vphp-database-fetch/
├── docker-compose.yml
├── run_benchmarks.sh # One-shot: seed + build + run all benchmarks + README
├── generate_readme.sh # Parses results and writes README.md
├── setup/
│ ├── Dockerfile
│ ├── populate.sql # Creates & seeds records table (10M rows)
│ └── entrypoint.sh
├── pdo/
│ ├── Dockerfile # php:8.5-cli-alpine + pdo_pgsql
│ ├── fetch.php # Streaming — FETCH_ASSOC, one array per row
│ ├── fetch_obj.php # Streaming — FETCH_OBJ, one stdClass per row
│ ├── fetch_class.php # Streaming — FETCH_CLASS, one typed object per row
│ ├── fetch_all.php # Fetch-all — FETCH_ASSOC, full result set in memory
│ ├── fetch_obj_all.php # Fetch-all — FETCH_OBJ, full stdClass array in memory
│ └── fetch_class_all.php # Fetch-all — FETCH_CLASS, full object array in memory
├── symfony/
│ ├── Dockerfile
│ └── src/
│ ├── Entity/
│ │ └── Record.php # Doctrine ORM entity
│ └── Command/
│ ├── FetchRecordsCommand.php # benchmark:fetch — streaming toIterable()
│ └── FetchAllRecordsCommand.php # benchmark:fetch-all — getResult() all in memory
└── laravel/
├── Dockerfile
├── app/Models/
│ └── Record.php
└── app/Console/Commands/
├── FetchRecordsCommand.php # benchmark:fetch — streaming cursor()
└── FetchAllRecordsCommand.php # benchmark:fetch-all — get() all in memory