__destruct runs at an unpredictable moment, and may not run

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.