php runtime and server environment
Garbage Collection
PHP frees memory automatically, but it is still worth understanding how that happens. Most beginner PHP code runs as a short web request, so memory disappears when the request ends. In workers, queue consumers, daemons, import scripts, and long-running runtimes, memory behaviour matters much more.
Garbage collection is PHP's way of finding values that are no longer reachable, especially values trapped in reference cycles.
Reference counting
PHP mostly manages memory with reference counting. When a value is no longer referenced by anything, PHP can release it.
<?php
declare(strict_types=1);
$items = range(1, 100000);
echo 'After create: ' . memory_get_usage(true) . PHP_EOL;
unset($items);
echo 'After unset: ' . memory_get_usage(true) . PHP_EOL;
// Prints:
// After create: 4198400
// After unset: 2097152
The exact numbers will vary by PHP version and platform. The useful lesson is that removing the last reference lets PHP reuse or release memory.
Cycles need garbage collection
A cycle happens when values reference each other. Each object still has a reference, so simple reference counting is not enough to free them.
<?php
declare(strict_types=1);
gc_enable();
$first = new stdClass();
$second = new stdClass();
$first->other = $second;
$second->other = $first;
unset($first, $second);
$collectedCycles = gc_collect_cycles();
echo 'Collected cycles: ' . $collectedCycles . PHP_EOL;
// Prints:
// Collected cycles: 2
PHP's cyclic garbage collector can find this kind of unreachable cycle. You normally do not need to call gc_collect_cycles() in a web controller, but it can be useful in long-running scripts after processing a batch of work.
Checking collector status
PHP exposes basic garbage collector status:
<?php
declare(strict_types=1);
gc_enable();
$status = gc_status();
echo 'runs: ' . $status['runs'] . PHP_EOL;
echo 'collected: ' . $status['collected'] . PHP_EOL;
echo 'threshold: ' . $status['threshold'] . PHP_EOL;
// Prints:
// runs: 0
// collected: 0
// threshold: 10001
These values help when investigating a worker that grows over time. They are not usually something you display in an application page.
Request-response versus long-running processes
In normal PHP-FPM request-response code, the process handles one request and clears request-local variables afterwards. That makes many small memory mistakes less visible.
In long-running code, the same process may handle thousands of jobs:
<?php
declare(strict_types=1);
foreach (range(1, 3) as $jobNumber) {
$rows = range(1, 50000);
echo 'Processed job ' . $jobNumber . PHP_EOL;
unset($rows);
gc_collect_cycles();
}
// Prints:
// Processed job 1
// Processed job 2
// Processed job 3
The unset() call removes a large temporary variable as soon as the job finishes. The explicit collection gives PHP a chance to clean cyclic garbage before the next job starts. In real workers, you would also monitor memory and restart the process after a safe number of jobs or a memory threshold.
Common causes of growing memory
Memory growth in PHP is often caused by keeping references longer than intended:
- appending every processed row to an array during a large import
- storing per-request data in a static property inside a long-running worker
- keeping ORM entities alive after each batch
- retaining closures that capture large objects
- logging or collecting debug data without clearing it
- event listeners that are registered repeatedly and never removed
Garbage collection cannot help if your code still has a legitimate reference to the data. If a global array keeps every processed record, PHP is doing what the code asked it to do.
Destructors are not cleanup plans
Objects can define __destruct(), but relying on destructors for important application behaviour is risky. Destructors run when objects are destroyed, and destruction timing can be less obvious when cycles, shutdown, fatal errors, or long-running processes are involved.
Use explicit cleanup for resources that matter: close file handles, release locks, commit or roll back transactions, and disconnect clients deliberately.
What you should be able to do
After this lesson, you should be able to explain reference counting, recognise cyclic references, use gc_collect_cycles() in a careful long-running script, and distinguish a real memory leak from code that is still holding references.
Practice
Task: Observe Cyclic Garbage
Create a small PHP script that builds an object cycle, removes the normal references, and asks PHP to collect the cycle.
Requirements
- Enable garbage collection with
gc_enable(). - Create two objects that reference each other.
- Remove the local references with
unset(). - Call
gc_collect_cycles()and print how many cycles were collected. - Print memory usage before and after the example.
- Add a short note explaining why this matters more in workers than in short web requests.
Check your work
The exact memory numbers do not need to match another machine. The script should demonstrate the concept and should not rely on a fatal error or memory exhaustion.
Show solution
One possible solution is to create a tiny cycle and then collect it explicitly.
<?php
declare(strict_types=1);
gc_enable();
echo 'Memory before: ' . memory_get_usage(true) . PHP_EOL;
$first = new stdClass();
$second = new stdClass();
$first->other = $second;
$second->other = $first;
unset($first, $second);
$collected = gc_collect_cycles();
echo 'Collected cycles: ' . $collected . PHP_EOL;
echo 'Memory after: ' . memory_get_usage(true) . PHP_EOL;
// Prints:
// Memory before: 2097152
// Collected cycles: 2
// Memory after: 2097152
This matters more in workers because the same PHP process keeps running after each job. A short PHP-FPM request gets a clean request-local state at the end of the request, but a queue worker can slowly grow if each job leaves arrays, objects, listeners, or cycles behind.