skip to content

In Laravel Octane, what do the --max-requests option and the garbage setting do about memory growth, and why are neither of them a fix for a leak?

level: middleimportance: should knowfreq 35%

answer

  1. recycle a worker after N requests
  2. default 500 requests
  3. garbage threshold 50 MB
  4. CollectGarbage listener commented out
  5. recycling hides, does not remove

basics

~10 s

--max-requests (default 500) gracefully replaces a worker after that many requests. garbage (50 MB) is read only by the CollectGarbage listener, which ships commented out. Both contain memory growth; neither removes a leak.

solid answer

~40 s

`php artisan octane:start --max-requests=250` sets how many requests a worker handles before it is restarted; the default is `500`, passed to Swoole as `max_request`, to RoadRunner as `http.pool.max_jobs` and to FrankenPHP's worker loop as `MAX_REQUESTS`. A replacement worker boots a fresh application, so memory that crept up is released. `'garbage' => 50` in `config/octane.php` is read only by the `CollectGarbage` listener, which calls `gc_collect_cycles()` after an operation when `memory_get_usage()` exceeds that many megabytes - and that listener ships commented out under `OperationTerminated`, so the setting does nothing until you enable it. Neither fixes a leak: recycling bounds its size, the collector frees only unreachable cycles, and anything still referenced - a static array, a shared singleton's property - survives both until the worker dies.

code

php · 20 lines
php
<?php

use Laravel\Octane\Contracts\OperationTerminated;
use Laravel\Octane\Listeners\CollectGarbage;
use Laravel\Octane\Listeners\FlushOnce;
use Laravel\Octane\Listeners\FlushTemporaryContainerInstances;

// config/octane.php (excerpt)
return [
    'listeners' => [
        OperationTerminated::class => [
            FlushOnce::class,
            FlushTemporaryContainerInstances::class,
            CollectGarbage::class, // uncommented: now reads 'garbage'
        ],
    ],

    // Megabytes; used only by CollectGarbage
    'garbage' => 50,
];

go deeper

for a junior

Recall that --max-requests restarts a worker after 500 requests by default, to limit memory growth under Octane.

for a middle

Explain how each server receives --max-requests, that garbage is read only by the commented-out CollectGarbage listener, and what gc_collect_cycles() can and cannot free.

for a senior

Show you treat recycling as a safety net, track the post-request memory floor on one worker, and fix the retaining code before tuning numbers.

for a principal

Balance boot cost against memory headroom per worker, and set a policy for when recycling tuning is acceptable versus when a leak must be fixed.

## Memory in a long-lived worker Under **Laravel Octane**, a worker's PHP process serves many requests. Memory that a request allocates and then drops is reused, but two kinds of growth can build up over hundreds of requests: - **Retained references** - data still reachable from something long-lived: a static array, a property on a singleton resolved at boot, a closure registered at boot. PHP cannot free it because it is still in use. - **Uncollected cycles** - objects that reference each other but are unreachable from the program. PHP's reference counting cannot free them; its cycle collector does, when it runs. Octane gives you two knobs that act on these symptoms. ## `--max-requests`: recycle the worker ```bash php artisan octane:start --max-requests=250 ``` Octane's docs describe it as a guard against stray memory leaks: a worker is **gracefully restarted** after handling the given number of requests. The default is **500**, from the start command's fallback `config('octane.max_requests', 500)`. Each server receives it differently: | Server | Setting Octane passes | |---|---| | Swoole / Open Swoole | `max_request` (and `task_max_request` for task workers) | | RoadRunner | `http.pool.max_jobs` | | FrankenPHP | `MAX_REQUESTS`, checked by Octane's worker loop, which exits after that many requests | The replacement worker boots a fresh application, so all memory the old one accumulated is returned. The cost is a boot per recycle and a brief moment with one fewer ready worker. ## `garbage`: force cycle collection ```php 'garbage' => 50, ``` This value is read by exactly one place: the `CollectGarbage` listener, which after an operation checks `memory_get_usage()` in megabytes and calls `gc_collect_cycles()` when usage is above the threshold. In the published config, `CollectGarbage::class` sits **commented out** under the `OperationTerminated` listeners, next to `DisconnectFromDatabases`. So by default the 50 MB setting has no effect; you enable it by uncommenting the listener. (PHP's own cycle collector still runs automatically based on its internal root buffer; the listener just adds a threshold-based run.) ## Why neither is a fix 1. **Recycling bounds the damage, it does not remove it.** A static array that gains 20 KB per request still costs up to 500 x 20 KB per worker before recycling, multiplied by the worker count. Double the traffic and workers recycle twice as often. 2. **Data leaks happen long before recycling.** If the retained state is a previous customer's data, the second request on the worker is already wrong. Recycling after 500 requests changes nothing about correctness. 3. **`gc_collect_cycles()` only frees garbage.** Data still referenced from a static or a boot-resolved singleton is not garbage; forcing collection does not touch it. 4. **Tuning hides trends.** Setting `--max-requests=50` to make a memory graph look flat removes the signal that something is leaking. ## Reading the symptom Plot worker memory after each request on a single worker. A sawtooth that returns to the same floor is normal allocation and collection. A floor that rises steadily between recycles is retained data; a floor that drops only when `CollectGarbage` runs points to cycles. Only the second pattern is what the `garbage` setting can help with. ## A sane approach - Keep the default `--max-requests` as a **safety net**, not as the plan. - In development and staging, run with `--workers=1` and watch the worker's memory across many identical requests: a steadily rising floor points to retained references. - Find the owner of the growth: static properties, memoizing singletons, arrays that only ever grow, listeners or macros registered per request instead of per boot. - Fix the owner; then, if memory still creeps because of third-party code you cannot change, lower `--max-requests` knowingly and enable `CollectGarbage` if cycles are the cause. (General leak diagnosis - heap snapshots, retained size, reading footprint charts - is a separate memory-management topic.)

  • How does FrankenPHP apply --max-requests under Octane?
    Octane starts FrankenPHP with a `MAX_REQUESTS` environment variable. Its `frankenphp-worker.php` loop handles requests while the count is below that value, then leaves the loop, terminates the worker and collects cycles; FrankenPHP starts a replacement worker script.
  • Would --max-requests=1 be a good way to make an app Octane-safe?
    It makes every request boot a fresh application, which removes cross-request state but also removes Octane's benefit, since booting per request is what Octane avoids. Octane's docs use it only as a development convenience in a Docker example. In production, fix the state instead.

saying these in an interview costs you the question

  • The garbage setting works out of the box with the published config
  • gc_collect_cycles() frees data held in static arrays
  • Lower --max-requests fixes cross-request data leaks
  • --max-requests defaults to unlimited, so workers never recycle
  • A worker hitting --max-requests drops its in-flight request