skip to content

In PHP, when exactly does __destruct run, and why is it risky to rely on a destructor to flush or commit work?

level: seniorimportance: should knowfreq 40%

answer

  1. last handle to the object goes away
  2. cycles wait for the collector or shutdown
  3. exit still runs destructors
  4. fatal errors skip them entirely
  5. failed constructor means no destructor

basics

~20 s

__destruct runs as soon as the last handle to an object disappears, or at shutdown if the object is still alive. Fatal errors skip it, cycles delay it and shutdown order is undefined, so commit work explicitly.

solid answer

~50 s

For an object outside any reference cycle, `__destruct()` runs **immediately** when the last variable, property or array element holding it goes away: `unset()`, reassignment, or the end of the function that held it. Objects caught in a **cycle** wait until the cycle collector runs or the request ends. Objects still alive at the end are destroyed during the **shutdown sequence**, after `register_shutdown_function()` callbacks, in no guaranteed order; `exit()` still triggers them. Three cases make them unreliable for real work: after a **fatal error** such as an exhausted `memory_limit` or an exceeded `max_execution_time` the engine marks all objects as destructed and **no destructor runs**; a destructor that throws during shutdown is itself a fatal error; and a constructor that threw leaves no destructor to run. So flushing a buffered ledger writer or committing a transaction belongs in an explicit `flush()`/`commit()` inside `try`/`finally`; a destructor is at most a safety net that logs.

code

php · 29 lines
php
<?php
declare(strict_types=1);

final class LedgerWriter
{
    private array $buffer = [];

    public function add(string $row): void { $this->buffer[] = $row; }

    public function flush(): void
    {
        // write $this->buffer to storage, then:
        $this->buffer = [];
    }

    public function __destruct()
    {
        if ($this->buffer !== []) {
            error_log(count($this->buffer) . ' ledger rows were never flushed');
        }
    }
}

$writer = new LedgerWriter();
try {
    $writer->add('2026-09-29;EUR;-2500');
} finally {
    $writer->flush(); // explicit, runs even if add() threw
}

go deeper

for a junior

Recall that __destruct runs when an object is no longer referenced, or at the end of the script.

for a middle

Explain immediate destruction versus cycles, the shutdown order with shutdown functions, and that exit still runs destructors.

for a senior

Treat destructors as unreliable for durable work: fatal errors skip them, shutdown order is undefined, and flushing belongs in explicit try/finally code.

for a principal

Set rules for resource lifecycles in long-running workers and request code, deciding where explicit close methods, finally blocks and destructor safety nets belong.

## What a destructor is `__destruct()` is the magic method PHP calls when an object is about to be freed. It takes no parameters and cannot declare a return type. Rather than leaving objects to a collector that runs "eventually", PHP destroys most objects **deterministically**, which makes destructors tempting for cleanup. The details decide whether that is safe. ## When it runs 1. **Last handle released.** When no variable, property, array element or closure still holds a handle to the object, it is destroyed at once. Typical triggers: `unset($writer)`, `$writer = null`, overwriting the variable, or returning from the function whose local variable was the only holder. 2. **Reference cycles.** If objects point at each other (parent and child, an entity and its collection, an object that stores a closure capturing `$this`), dropping your last variable does not free them. They are destroyed when PHP's cycle collector runs, which happens when its internal buffer fills or when `gc_collect_cycles()` is called, or at the end of the request. 3. **Shutdown.** At the end of a script or request, PHP first runs callbacks registered with `register_shutdown_function()`, then calls the destructors of objects still alive, then flushes output buffers. The manual states destructors at this stage run "in any order". 4. **`exit()`.** Calling `exit()` still runs destructors during shutdown. Calling `exit()` *inside* a destructor stops the remaining shutdown routines. Under PHP-FPM every request ends with this shutdown, so an object never survives into the next request and its destructor runs at the latest when the request finishes, unless the request dies with a fatal error. ## When it does not run | Situation | Destructor runs? | |---|---| | normal end of script or `exit()` | yes, during shutdown | | fatal error: `memory_limit` exhausted, `max_execution_time` exceeded | **no**, all objects are marked destructed | | the constructor threw | **no**, the object is flagged at the failed construction | | the process is killed from outside | no | | the destructor stored `$this` somewhere | not a second time | The fatal-error row is the one that bites in production: a batch job that runs out of memory halfway never reaches the destructor that was supposed to flush its last rows. ## Other shutdown-time surprises - **Throwing** from a destructor that runs during shutdown is a fatal error. - **Headers** may already have been sent, so a destructor cannot reliably set a header or cookie. - The **working directory** can differ during shutdown under some server APIs, so relative paths may point elsewhere. - Collaborators the destructor needs (a PDO connection, a logger) may already have been destroyed, because the order is not guaranteed. ## What to do instead - Put work that must happen in an explicit method (`flush()`, `commit()`, `close()`) and call it in `try`/`finally`, so it runs on exceptions too. - Keep destructors for **releasing** things the process would release anyway (a lock file handle, a temporary file), or as a **safety net** that logs a warning such as "writer destroyed with 12 unflushed rows". - In long-running workers, break cycles explicitly or call `gc_collect_cycles()` at known points if destructor timing matters. ## Long-running workers Queue consumers and daemons started from the CLI never reach shutdown between jobs, so destructor timing becomes a design issue: - An object caught in a reference cycle keeps its resources (a file handle, a lock) until the cycle collector happens to run. - A destructor that relies on end-of-request cleanup never gets it, because there is no request boundary. - Calling `gc_collect_cycles()` after each job makes destruction of cyclic garbage happen at a known point, at some CPU cost. Explicit `close()` calls per job remain the reliable option; the collector only decides when forgotten objects disappear. ## Summary PHP destroys acyclic objects the moment their last handle disappears and everything else during shutdown, but fatal errors, failed constructors and shutdown ordering mean a destructor is never a guarantee. Commit explicitly; use the destructor to clean up or to shout when someone forgot.

  • Why might a destructor never run in a script that exhausts its memory limit?
    Exceeding `memory_limit` is a fatal error. On a fatal error PHP marks every object in the object store as already destructed before bailing out, so shutdown skips all `__destruct()` calls. Shutdown functions registered with `register_shutdown_function()` still run, which is why fatal-error reporting is usually hooked there instead.
  • Two objects reference each other and you `unset()` both variables. When do their destructors run?
    Not immediately. Each object still holds a handle to the other, so neither becomes unreachable by ordinary release. They are destroyed when the cycle collector next runs, triggered automatically or by `gc_collect_cycles()`, or at the end of the request, whichever comes first.

A destructor is a last-one-out-turns-off-the-lights rule: on a normal day the lights go off the moment the last person leaves. But if the building is evacuated because of a fire alarm, nobody stops at the switch, which is what a fatal error does to every pending destructor.

saying these in an interview costs you the question

  • Destructors run at an unpredictable time chosen by the garbage collector.
  • Destructors always run, even after a fatal error, because shutdown is guaranteed.
  • exit() skips all destructors.
  • Objects in a reference cycle are destroyed as soon as you unset your variable.
  • Destructors at shutdown run in reverse order of creation, guaranteed.