A destructor runs when the last reference is dropped, which is not the same as when the variable goes out of scope, and not at all on a fatal error.
final class Lock
{
public function __destruct() { $this->release(); }
}
// released here? only if nothing else holds a reference —
// a closure, a static registry, an exception's trace
function f(): void { $lock = new Lock(); /* ... */ }
// and NOT released at all on:
// a fatal error
// exit() inside a shutdown function
// a reference cycle, until the GC runs
The exception trace case is the one that catches people: an exception thrown while an object is on the stack holds a reference in its trace, so the destructor runs when the exception is finally released rather than at the throw. Anything that must be released — a lock, a file handle, a database transaction — deserves an explicit call in a finally block, with the destructor as a backstop rather than the mechanism.