The five timeout layers
A Laravel HTTP request touches multiple layers. Each has its own timeout:
| Layer | Default | Config |
|---|---|---|
| Client (browser, curl) | Varies (~30s) | Client-specific |
| Load balancer (ALB, CF) | 60s AWS ALB default | idle_timeout |
| Nginx upstream | 60s | fastcgi_read_timeout |
| PHP-FPM request | 0 (unlimited) | request_terminate_timeout |
| PHP script | 30s | max_execution_time |
| Database statement | Unlimited (dangerous) | statement_timeout (Postgres) |
Diagnose the layer that failed
Nginx access log
# Enable timing fields in log_format
log_format upstream_timing '$remote_addr $request $status '
'request=$request_time upstream=$upstream_response_time';
# Look for 504s with timing
grep ' 504 ' /var/log/nginx/access.log | tail
If upstream_response_time is ~equal to fastcgi_read_timeout, Nginx gave up. If upstream_response_time is -, PHP-FPM didn't respond at all (crashed, full worker pool, or no workers running).
PHP-FPM slowlog
/etc/php/8.2/fpm/pool.d/www.conf
slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 10s
request_terminate_timeout = 60s
PHP-FPM dumps the stack trace of any request taking longer than request_slowlog_timeout. Priceless for catching where the hang actually happens — often inside a database driver or file operation.
Database-level timeouts
config/database.php (PostgreSQL)
'pgsql' => [
'driver' => 'pgsql',
// ...
'options' => [
PDO::ATTR_TIMEOUT => 5,
],
// Per-session statement timeout
'statement_timeout' => '15s',
],
Without statement_timeout, a query can hold a PHP-FPM worker indefinitely. Set a value lower than your request timeout so DB hangs fail recoverably instead of taking out the worker.
Common 504 causes
- Synchronous external HTTP call with no timeout. Default Guzzle = unlimited. Set
Http::timeout(5)on every external call. - Slow DB query on unindexed column.
EXPLAIN ANALYZEreveals full-table scans. - Exhausted PHP-FPM worker pool.
pm.max_childrentoo low, legitimate requests queue, queue exceeds timeout. - Lock contention on a hot row.
SELECT ... FOR UPDATEblocking other requests. Visible in Postgres'spg_locks. - Blocking cron job on the same server. A scheduled task hogging CPU or disk starves web workers.
Don't just raise every timeout
The instinct to bump fastcgi_read_timeout to 300s papers over the real issue. A request taking 300s shouldn't run in a request at all — queue it. Use a synchronous request for the queue dispatch and return a 202 with a polling URL or a webhook subscription.
THE EASY WAY
NightOwl surfaces slow and timed-out requests with trace drilldown
Every request is recorded with duration, status, and full per-span breakdown. Filter by 504 or slow duration to find timeouts; click a request to see the dominant span — usually the DB query or external HTTP call that hung.
composer require nightowl/agent
php artisan nightowl:installFrom $5/month flat. Data in your PostgreSQL.