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 example
<?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 example
<?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 example
<?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 example
<?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 example
<?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.