Contents
- 1 How PHP garbage collection works (and why it sometimes feels like magic)
- 2 The mental model: PHP, memory, and little invisible counters
- 3 Reference counting: the quiet accountant inside Zend
- 4 Circular references: where reference counting fails
- 5 The cycle collector: PHP’s quiet janitor
- 6 A real-world pain: memory growth in a queue worker
- 7 The tools: gc_enable, gc_disable, gc_status
- 8 Arrays, copies, and unexpected memory
- 9 Frameworks, containers, and hidden cycles
- 10 Long-running PHP and the myth of “it’s just a scripting language”
- 11 Profiling memory: when intuition isn’t enough
- 12 Practical rules that have saved me (and others)
- 13 PHP’s garbage collector is not your enemy
- 14 The human side: what this means for our work
- 15 For people hiring and people looking for work
- 16 Ending quietly
How PHP garbage collection works (and why it sometimes feels like magic)
There’s this moment most of us have lived through:
It’s around 1:30 in the morning.
Production is “mostly fine”, except for that one PHP-FPM pool that keeps getting mysteriously killed.Out of memory (allocated …) in the logs.
Your coffee has gone cold. Your patience too.
You add a few memory_get_usage() calls, reload the page a couple of times, and watch the numbers creep up. Again. And again. You didn’t store gigabytes of data anywhere. You didn’t intentionally leak anything. But memory clearly disagrees.
At some point someone says the phrase we all dread:
“Maybe it’s the garbage collector?”
And suddenly the room is quiet.
We talk about PHP frameworks, PHP async, PHP 8 performance, DDD, CQRS, all that heavy architecture stuff. But the thing that decides whether your long-running PHP process lives or dies?
Often it’s a very quiet, very patient part of the engine: garbage collection.
Let’s unpack it. Slowly. Like a developer cleaning up after a long sprint.
The mental model: PHP, memory, and little invisible counters
Imagine a typical request in a PHP web app:
- Router builds a request object
- Controller creates a service
- Service talks to a repository
- Repository hydrates entities
- Entities get serialized to JSON, or rendered in Twig/Blade
Behind all of that, PHP is pulling memory from the system to store:
- strings
- arrays
- objects
- closures
- and all the weird intermediate stuff in between
You write something like:
$user = $userRepository->find($id);
$profile = $profileService->buildProfile($user);
return $responseFactory->json($profile);
When this function ends, those variables fall out of scope. You don’t “free” them manually. You just trust PHP.
The default story we tell ourselves is:
“Request ends → PHP process ends → everything is freed.”
That’s true for classic PHP-FPM where each request is handled in isolation and the process eventually gets recycled.
But:
- CLI scripts that run for hours
- daemons with PHP-PM, RoadRunner, Swoole
- queue workers that live all day
- WebSocket servers, long-running watchers
These don’t have the luxury of “just restart everything every request”.
Here, how PHP garbage collection works is not a theoretical curiosity. It’s the difference between “runs fine” and “dies mysteriously at 3 a.m.”
Reference counting: the quiet accountant inside Zend
At the heart of PHP’s memory management is something very simple and very brutal: reference counting.
Every value in PHP—every zval in engine-speak—carries a little integer called refcount.
Roughly:
- Create a variable →
refcountbecomes 1 - Assign it to another variable →
refcountincrements - Unset or let a variable go out of scope →
refcountdecrements - When
refcounthits 0 → memory is freed immediately
Example:
$user = ['name' => 'Alice']; // refcount = 1
$alias = $user; // refcount = 2 (same underlying array)
unset($user); // refcount = 1
unset($alias); // refcount = 0 → memory freed
You don’t see those counters, but the engine keeps them religiously. This is the fast path for memory reclaiming. No GC cycle is needed. No scanning. No clever algorithm.
This is why in many PHP scripts, you never even feel garbage collection. Reference counting does the heavy lifting.
But this simple model has a huge blind spot.
Circular references: where reference counting fails
Here’s the trap.
Imagine two objects that point to each other:
class Node {
public ?Node $next = null;
}
$a = new Node();
$b = new Node();
$a->next = $b;
$b->next = $a;
unset($a, $b);
When you call unset($a, $b), what happens?
$aand$bvariables disappear- but the objects they used to refer to still have references to each other:
$a->nextpoints to$b$b->nextpoints to$a
So their refcount never drops to zero.
From the perspective of the reference counter: “Someone still cares about these objects.”
From the perspective of reality: “No one does. They’re garbage.”
This is the situation that reference counting alone cannot handle:
circular references with no external owner.
That’s where PHP’s cycle-collecting garbage collector comes in.
The cycle collector: PHP’s quiet janitor
Since PHP 5.3, the engine has a dedicated subsystem to detect and free cycles of garbage.
The idea is very human:
- Gather a list of objects that might be part of cycles
- Temporarily pretend that all references between them don’t matter
- See which ones are still reachable from the outside world
- Whatever’s left, unreachable → free it
Rough analogy:
You have a group of people in a room. Each person points at another person and says “I know them.”
You then ask: “But who is known by someone outside this room?”
Everyone who’s only known by people within the room is effectively invisible to the rest of the world.
That’s garbage.
When does PHP run garbage collection?
PHP doesn’t run the GC on every request or every variable destruction. That would be overkill.
Instead, it keeps a kind of buffer. When certain thresholds are reached, it triggers a GC cycle.
We don’t usually need to tune those thresholds manually, but the point is: collection is not constant; it’s episodic.
It runs when PHP suspects there’s enough potential garbage that it’s worth the effort.
You can, if you really want, call it by hand:
$collected = gc_collect_cycles();
echo "Collected $collected cyclic garbage zvals\n";
There’s a small thrill in seeing the number go up. It’s like watching the engine confess:
“Okay, fine, I did have 42 unreachable objects lying around.”
A real-world pain: memory growth in a queue worker
Picture a typical job worker:
while (true) {
$job = $queue->reserve();
if (!$job) {
sleep(1);
continue;
}
processJob($job);
$queue->delete($job);
}
Inside processJob() you:
- hydrate entities
- log a bunch
- maybe build a few large arrays
- fire events through a dispatcher
You watch memory_get_usage() at the end of the loop. It grows. Slowly. But persistently. You suspect a leak.
You start unsetting things:
function processJob($job) {
$user = $userRepository->find($job->userId);
// do stuff...
unset($user);
}
Still growing.
So you get more aggressive:
gc_collect_cycles();
And suddenly, memory usage drops between iterations. The leak “disappears”.
Most likely you had circular references buried somewhere:
- Event listeners capturing
$this - Closures holding references to service containers
- Lazy-loaded relationships in an ORM creating bi-directional graphs
PHP’s reference counting couldn’t free them on the spot.
The GC eventually cleaned them up—but not often enough to keep your worker’s memory stable over hours.
So you use gc_collect_cycles() as a kind of manual broom at the end of each iteration.
Is it always necessary? No.
Can it be justified in long-running processes? Sometimes, absolutely.
The tools: gc_enable, gc_disable, gc_status
You don’t have to treat garbage collection like a black box. PHP gives you a few levers:
gc_disable(); // Turn off automatic cycle collection
gc_enable(); // Turn it back on
$status = gc_status(); // Get info about runs, collected cycles, etc.
On some workloads, especially benchmark-y ones, people disable GC temporarily to avoid its pauses, then run it manually at a predictable time.
Imagine a long CLI import:
gc_disable();
foreach ($bigDataset as $index => $item) {
handleItem($item);
if ($index % 1000 === 0) {
$collected = gc_collect_cycles();
echo "GC collected $collected cycles after $index items\n";
}
}
gc_enable();
You take control of when the cleanup sweeps the room, instead of letting it interrupt you whenever it feels like it.
Is this micro-optimization? Often.
But in some ETL pipelines or large imports it can be the difference between “finishes in 3 hours” and “slowly grinds to a halt”.
Arrays, copies, and unexpected memory
There’s another part of PHP that conspiratorially interacts with garbage collection: copy-on-write.
Simple version:
$data = loadHugeArray(); // refcount = 1
$copy = $data; // refcount = 2, still one underlying array
As long as neither $data nor $copy is modified, they share the same memory.
The moment you change one:
$copy['new'] = 123; // Now the engine creates a real copy
Now you potentially have two huge arrays.
If you forget to let one of them die, the GC doesn’t help much, because:
- these aren’t cyclic garbage
- reference counting alone will handle them, but only when all references go out of scope
So that innocent “I’ll just keep the original array around in case I need it later” can quietly double your memory footprint.
When you’re working with large datasets in PHP:
- prefer streaming (generators, cursors)
- avoid unnecessary duplication
- explicitly
unset()big temporary arrays as soon as you’re done
unset() isn’t a magic GC button; it just helps the reference counters hit zero earlier.
In modern PHP applications we layer complexity:
- PSR-11 containers
- Event dispatchers
- Middleware stacks
- ORM entities with bidirectional relationships
- Closures passed all over the place
Each of these is a potential source of cycles.
Common patterns:
- Service container holds a logger; logger holds a reference to container (for some reason)
- Entity A has a reference to B; B has a reference back to A
- Event subscribers capturing
$thisin closures, and that$thisitself points to more objects
In a request-per-process world, these cycles are reset every request. You never notice.
In a long-running PHP app, they persist. They accumulate. And then some poor soul opens htop and wonders why each worker is at 800 MB.
If you’re building libraries or frameworks in PHP, understanding how garbage collection works is not an academic exercise. It’s an ethical one. You’re shaping the memory behavior of thousands of other people’s applications.
Sometimes the most responsible design is:
- breaking circular relationships
- being explicit about lifetimes
- not letting random closures capture the entire outer
$this
Or even something as small as:
$closure = function () use ($hugeArray) {
// ...
};
// vs:
$closure = function ($item) {
// ...
};
The first one closes over everything, including large data structures.
The second takes only what it needs.
Tiny design differences. Huge impact on how the engine can clean up after you.
Long-running PHP and the myth of “it’s just a scripting language”
For years, PHP had this reputation: “It starts, it does a thing, it dies.”
Memory problems? Just wait until the process exits.
That world is gone.
On platforms like Find PHP, the roles you see posted increasingly mention:
- “experience with high-load PHP systems”
- “long-running workers and queues”
- “optimizing PHP memory usage in production”
We’re running:
- Laravel Horizon workers that never stop
- Symfony Messenger consumers that live for days
- Swoole or RoadRunner serving HTTP without restarting between requests
- Observability agents embedded inside the process
The old mental model—“memory is automatically freed because PHP is short-lived”—doesn’t protect us anymore.
Now, memory leaks in PHP look a lot more like memory leaks in any other language:
- some objects are kept alive unintentionally
- cycles stay hidden
- caches slowly grow
- GC has to decide what to collect and when
If you’re hiring a “senior PHP developer” and they’ve never heard of gc_collect_cycles() or circular references, that shows. Not immediately. Not in their first PR. It shows six months later when the system buckles under real production load.
Profiling memory: when intuition isn’t enough
There’s a dangerous kind of optimism we all share:
“I don’t see anything obviously wrong, so it’s probably fine.”
Memory doesn’t care about our optimism.
When a PHP application behaves strangely with memory, the only honest move is to measure:
- add
memory_get_usage()andmemory_get_peak_usage()in strategic places - log memory after each iteration of a worker loop
- compare different paths (e.g. certain jobs causing more growth than others)
Sometimes you’ll see a pattern like:
- memory grows to a plateau, then stabilizes → probably just caches warming up
- memory grows linearly with every job → you have a leak
- memory spikes after certain types of requests → maybe a specific code path is retaining cycles
In those moments, knowing that PHP has both:
- reference counting and
- cycle collection
gives you a language to think in:
- “Is this a cycle problem?”
- “Am I simply holding references longer than I should?”
- “Is GC even enabled here?”
- “What happens if I run
gc_collect_cycles()after this block?”
You start to move from superstition to diagnosis.
Practical rules that have saved me (and others)
Not theory now. Just a few patterns that have repeatedly helped in real jobs, under real pressure.
-
Don’t hold the world in
$this.
The classic: a long-lived service that stores everything in properties “just in case”. Those properties might include big arrays, cache layers, even containers. When a closure captures$this, you’ve unintentionally created a spiderweb of references. -
Be careful with events and listeners.
Event dispatcher systems can easily create cycles: dispatcher → listener → service → dispatcher. Document the lifecycle. Make sure there’s a clean way to detach listeners. -
Limit lifetime in workers.
Instead ofwhile (true), try:$processed = 0; while ($processed < 1000 && $job = $queue->reserve()) { processJob($job); $queue->delete($job); $processed++; } // let supervisor restart the worker after 1000 jobsSometimes the simplest garbage collector is: exit and let the OS reclaim everything.
-
Use
gc_status()in staging.
That function is not glamorous, but it tells you how many cycles have been collected. If you see huge numbers there, it means your app naturally creates a lot of circular structures. -
Avoid “just in case” caching without limits.
A static array cache that “just keeps stuff” forever inside a long-lived process is a memory balloon. Tie caches to scopes, set limits, use LRU strategies, or rely on external caches (Redis, Memcached).
PHP’s garbage collector is not your enemy
There’s a tendency to blame GC when things feel slow:
- “Garbage collection pauses are killing performance.”
- “PHP’s GC is buggy.”
- “I disabled GC and it got faster.”
Sometimes those claims are true in isolated benchmarks. Often they’re just frustration.
The garbage collector is doing something we would absolutely hate doing manually: following complex object graphs and making decisions about what can safely disappear.
The truth is harsher:
- If your app constantly creates complex, cyclic graphs of objects, GC will have to work harder.
- If you design lifetimes clearly, keep graphs simple, and avoid unnecessary cycles, GC mostly becomes a non-event.
The enemy isn’t GC. It’s unintentional complexity.
Think about it: PHP’s cycle collector doesn’t invent cycles. It merely sees the ones we created.
The human side: what this means for our work
We like to think of code as this clean logical structure. But most of us know the reality:
- late evenings trying to debug a leak while Slack keeps blinking
- quiet anxiety when a worker process silently grows to 1 GB
- that small flush of relief when you finally see memory usage go down after a fix
Understanding garbage collection changes the emotional tone of those nights.
Instead of feeling lost, you can reason:
- “If memory keeps growing, then something still holds references.”
- “If cleaning circular references helps, maybe some closure or entity graph is the culprit.”
- “If
gc_collect_cycles()barely collects anything, maybe it isn’t about cycles at all.”
Knowledge doesn’t remove the stress, but it gives it boundaries.
You’re not just staring at graphs; you’re talking to the engine in its own language.
For people hiring and people looking for work
On a platform like Find PHP, there’s this quiet negotiation happening all the time:
- companies looking for PHP developers who “understand performance”
- developers trying to show they’ve gone beyond just writing controllers
Memory management is one of those underrated signals.
If you see a CV mention:
- work on long-running PHP processes
- debugging memory leaks
- using
gc_status(),gc_collect_cycles(),memory_get_usage() - optimizing entity graphs or event systems
you’re not just seeing buzzwords. You’re seeing someone who’s already spent those anxious evenings talking to the GC, figuring out where the cycles hide.
And if you’re that person, you know the feeling:
When someone else on the team runs into a leak and you can calmly say:
“Let’s check GC status and look for cycles. We’ll find it.”
That calm is earned.
Ending quietly
In the end, PHP’s garbage collection is just a conversation between your code and the engine:
- You create objects, arrays, graphs.
- The engine counts references, tracks roots, looks for cycles.
- Occasionally it rolls up its sleeves and cleans up what you left behind.
Some evenings it feels like magic. Other nights, like betrayal.
But the more you understand how it works—reference counting, cycles, GC triggers, long-running process patterns—the less mysterious those nights become.
You still have cold coffee, deadlines, and logs full of warnings.
But you also have a mental map of where the garbage hides, and how to gently guide PHP to let it go.
And some night, not far from now, you’ll watch memory flatten in your graphs, close your editor, and feel that quiet, steady satisfaction of something messy finally becoming simple enough to trust.