The finally Guarantee: Cleanup on Every Exit Path
A handler that opens a lock and forgets to release it on the error path does not crash — it wedges every later request that needs that lock. The bug is structural: cleanup written after the risky work runs only if the work succeeds.
$fp = fopen('/var/lock/inventory.lock', 'w');
flock($fp, LOCK_EX);
$result = updateStock($sku, $delta); // throws? the lock below never runs
flock($fp, LOCK_UN);
fclose($fp);
return $result;
finally is the language's answer — a block that runs whenever the try is left, no matter how:
$fp = fopen('/var/lock/inventory.lock', 'w');
try {
flock($fp, LOCK_EX);
return updateStock($sku, $delta); // success AND return both pass through finally
} finally {
flock($fp, LOCK_UN);
fclose($fp); // runs on success, on exception, on return
}
The exit-path matrix is worth memorizing, because it is the whole topic:
How the try ends |
finally runs? |
|---|---|
| falls off the end (success) | yes, before the function returns |
return inside try |
yes — the return is deferred until finally completes |
throw with no matching catch |
yes — the exception resumes unwinding after |
matching catch handled it |
yes, after the catch block |
exit() / die() |
no — process termination skips it |
| fatal error (OOM, parse) | no — no userland code runs |
Two consequences follow. First, return inside try is safe with respect to cleanup: the returned value is computed, then finally runs, then the function actually returns — so the lock is always released before any caller observes the result. Second, the finally block is the only place cleanup belongs when multiple paths share it: writing flock/fclose after the try AND again in the catch duplicates the release in two places that drift the moment either side changes.
A try with no catch and only a finally is legal and reads precisely: "nothing to recover here, just guarantee the release". That form suits code where the caller is the designated handler — your function's job is the resource, theirs is the policy. Note also what finally is not: it is not a substitute for the handler-coverage discussion in Registering Custom Error Handlers — a notice-turned-exception still passes through your finally exactly like any throw — and it is not destructors, whose non-deterministic timing (see the pitfalls chapter) makes them the wrong tool for this job. With the mechanism fixed, the next chapter maps it onto the concrete resources PHP hands out.
Read the block fluently and the intent is unmistakable: the try is the work, the finally is the release, and no third location exists for the same cleanup — which is exactly what makes it refactor-proof. A common question at this stage — does finally delay the return? — has a precise answer: the return value is computed first, then finally runs to completion, then the value is delivered to the caller. The observable effect is that any code the finally touches still sees the function's resources alive, and any exception the finally throws replaces the pending return entirely. That last rule returns in the pitfalls chapter as one of the pattern's two sharp edges.
Files, Locks, and Transactions: What Actually Needs Releasing
The finally pattern generalizes to every resource PHP tracks externally — the ones whose release cannot be left to request shutdown because something else holds them too.
File handles and locks are the teaching case because their failure mode is visible. PHP's garbage collector closes file handles eventually, but locks are process-level coordination primitives: an unreleased LOCK_EX blocks other processes (other FPM workers, cron jobs) until your handle is actually closed, not until your script forgets about it. Release order matters — flock($fp, LOCK_UN) before fclose() — since closing releases the lock anyway, but explicitness survives refactors that replace the handle mid-function.
Database transactions are the expensive case. An open transaction holds row locks and can stall unrelated writers; a half-committed one corrupts invariants:
$pdo->beginTransaction();
try {
$pdo->exec("UPDATE accounts SET cents = cents - 500 WHERE id = 1");
$pdo->exec("UPDATE accounts SET cents = cents + 500 WHERE id = 2");
$pdo->commit();
} catch (\Throwable $e) {
$failed = true; // marker for the finally below
throw $e; // rethrow — the caller decides policy
} finally {
if (isset($failed) && $pdo->inTransaction()) {
$pdo->rollBack(); // exactly-once rollback, exception or not
}
}
The inTransaction() guard is deliberate: if the exception came from beginTransaction() itself, or the commit already closed the transaction, a blind rollBack() would raise a second exception from inside finally — masking the original, as the pitfalls chapter details. Markers like $failed, or simply the inTransaction() state check, keep cleanup idempotent.
Stream contexts, curl handles, and PSR-7 responses follow the same shape with different verbs; modern PHP softens some of them — CurlHandle objects free their socket when garbage-collected, and PHP 8.0's fpm-closing of sockets at request end is a backstop, not a plan. The criterion for "does this need finally?" is one question: is anything outside this process affected until release? Locks: yes, other workers wait. Transactions: yes, other writers wait and invariants dangle. A local array: no — leave it to scope. Notice what the pattern replaces: the __destruct temptation from Magic Methods like __get and __toString. A destructor fires at non-deterministic garbage-collection time — never for objects a registry holds, moot at web-request teardown — while finally fires at a known point on a known path. For locks and transactions, known-point release is not a nicety; it is the difference between a system that recovers from errors and one that accumulates wedged state. The remaining sharp edges — returns in finally, exceptions thrown from finally, and the exit() blind spot — are exactly what the last chapter dissects.
Ownership transfer is the one case that inverts the pattern. When a function hands a resource to something that outlives it — a file handle passed into a writer object, a transaction spanning several repositories — the releaser is no longer the opener, and finally at the opening site would close a resource someone else now owns. The PHP-ecosystem convention is therefore explicit: a method that receives ownership says so in its name or docblock, and the original site's finally releases only what it still holds. Confusion here produces the two classic opposites — double release, where a finally closes a handle the callee was still using, and leak-on-success, where the happy path transfers ownership but the error path forgets. Name the owner, and the bug becomes a review comment instead of a midnight page.
Return Inside try, Exceptions in finally, and the exit() Blind Spot
The finally guarantee has edges, and each one produces a distinct, memorable bug.
A return inside finally overrides everything. If the try was returning a value or propagating an exception and the finally block also executes return, the finally's return wins — silently swallowing the exception in the process:
function findUser(int $id): ?array
{
try {
return $db->find($id); // or: throw new OfflineException();
} finally {
return null; // BUG: discards the value AND swallows the throw
}
}
The rule: a return, throw, or break inside finally cancels whatever the try or catch had pending. This is why style guides treat return in finally as a review-blocking defect — it converts your guaranteed-cleanup block into a guaranteed-silent-failure block. Cleanup code should only clean up.
An exception thrown inside finally masks the original. If the try was already unwinding with PaymentException and the finally throws (the blind rollBack() from the last chapter, a null-dereference in cleanup code), the new exception replaces the old one — and PHP 7.3+ attaches the original as $previous (you will see both in the trace as "During handling of the above exception, another exception occurred"), but many reporters surface only the top one. Treat cleanup code as hostile-environment code: guard it (if ($pdo->inTransaction())), and never let a helper's failure escape your finally unexamined.
exit() and die() skip finally entirely. They terminate the process immediately — no unwinding, no finally, no destructors-on-collection. Code that calls exit() deep in a locked section will hold that lock until the OS reclaims the handle. The fix is architectural: return an error value or throw, and confine the single legitimate exit — if any — to the outermost request layer where no resources are held. Fatal errors (OOM, parse failures in included files) have the same blind spot; that is the coverage gap log_errors and shutdown functions were enlisted for in the error-handler topic.
One composition note to finish: finally nests and composes cleanly with the rest of the toolset — catch-and-rethrow above it, error handlers converting notices into exceptions that still traverse it, and multiple finally blocks layering when scopes nest (innermost first, like try blocks everywhere). The mental model to carry forward is that PHP gives exactly one deterministic release point per scope, and it is the block named for it. What finally guarantees is that release happens; it cannot guarantee the release was recorded or diagnosed — that is the job of the last piece of the error-handling story: Logging Errors for Production.
One pattern composes the whole phase cleanly: fail loudly, clean deterministically, report once. The throw supplies the loud failure, finally supplies the deterministic release, and the outermost handler from the error-handling topics supplies a single structured report via the logger, chaining whatever context the translation layers added. Code that attempts all three inline — logging at every layer and releasing in catch blocks — produces duplicate log lines per incident and release-order bugs per refactor; code that attempts none produces silent wedges. PHP gives each job exactly one mechanism: exceptions for propagation, finally for release, handlers and loggers for reporting. Keep the three in their own lanes, and the tools of this phase stay small, composable, and hard to misuse — the mark of an API designed for failure.
Quiz
1. The try block returns a value, throws an uncaught exception, and falls through normally on different runs. When does the finally block run?
2. What does a `return` statement inside a finally block do?
3. Why guard a rollback with `if ($pdo->inTransaction())` inside finally?
4. Why is __destruct unsuitable for releasing a lock held by an object?
Flashcards
Question
When does a finally block run, and which exits skip it?
Click to reveal answer
Answer
It runs whenever the try is exited: normal completion, return (deferred until finally finishes), uncaught throw (unwinding resumes after), and after a matching catch. Skipped only by process-level termination: exit()/die() and fatal errors (OOM, parse) — which is why those belong at the outermost layer with no resources held.
Question
Why does cleanup belong in finally rather than after the try or in the catch?
Click to reveal answer
Answer
Code after the try runs only on success; duplicating release in catch and success paths creates two copies that drift when either changes. finally is the single place that executes on every path, so the release exists exactly once and cannot be skipped by a new throw.
Question
The two hazards of control-flow statements inside finally
Click to reveal answer
Answer
A return/throw/break in finally cancels whatever the try or catch had pending: a return discards the try's value and silently swallows in-flight exceptions; a throw raised by the cleanup code itself masks the original failure (chained as $previous since 7.3, but often hidden by reporters). Keep finally blocks side-effect-guarded and return-free.
Question
What is the idempotent-rollback pattern, and why use it?
Click to reveal answer
Answer
Guard cleanup with state checks — `if ($pdo->inTransaction()) $pdo->rollBack();` or a $failed marker — so the release runs exactly once and only when there is something to release. A blind rollback after beginTransaction() failure raises a second exception from finally that buries the first.
Question
Which resources truly need finally, and what is the test?
Click to reveal answer
Answer
Anything whose non-release affects the outside process: exclusive file locks (other workers wait), open database transactions (row locks, dangling invariants), cross-process coordination handles. The test: 'is anything outside this process affected until release?' Local arrays and pure computations fail the test — scope is enough.
Revision Notes
Key Takeaways
- 1. finally runs on every exit from try — success, deferred return, and uncaught throw — and is the single place shared cleanup belongs
- 2. exit()/die() and fatal errors skip finally; that blind spot is why resources must be held only in the innermost scope possible
- 3. A return inside finally silently discards the try's value and any in-flight exception — treat it as a review-blocking defect
- 4. Cleanup code must be defensive: guard releases with state checks (inTransaction) so a second exception cannot mask the first
- 5. Locks and transactions pass the 'outside world is affected' test for finally; destructors, with their non-deterministic timing, do not replace it
Interview Tips
- • "When does finally execute?" — on every try exit: success, return (deferred), uncaught throw (resumes after); then volunteer the exceptions: exit() and fatal errors skip it
- • "Return in finally — what happens?" — it overrides the try's return AND cancels pending exceptions; frame it as the classic silent-swallow bug and a review-blocking pattern
- • "How do you handle a database transaction safely?" — begin in try, commit on success, rethrow with context on failure, guarded rollback in finally using inTransaction(); mention idempotence so rollback runs exactly once
- • "finally or __destruct for cleanup?" — finally: deterministic, known-path, runs before the caller sees the result; destructors fire at GC time and never for globally-held objects in workers, so they are a backstop, not a plan
Cheat Sheet
Cleanup with finally
$fp = fopen('/var/lock/inventory.lock', 'w');
try {
flock($fp, LOCK_EX);
return updateStock($sku, $delta); // value computed, THEN finally runs
} finally {
flock($fp, LOCK_UN); // runs on success, return, and throw
fclose($fp);
}
Exit-path matrix:
| Path | finally |
|---|---|
success / return in try |
yes (return deferred) |
uncaught throw |
yes (unwinding resumes after) |
handled by catch |
yes (after the catch) |
exit()/die() |
no |
| fatal error (OOM/parse) | no |
Transaction pattern:
$pdo->beginTransaction();
try {
/* transfers */
$pdo->commit();
} catch (\Throwable $e) {
$failed = true;
throw $e; // rethrow — caller decides policy
} finally {
if (isset($failed) && $pdo->inTransaction()) {
$pdo->rollBack(); // idempotent, exactly-once
}
}
Hazards: return in finally swallows values and exceptions (review-blocking); a throw from cleanup masks the original (chained as $previous since 7.3, still easy to miss); exit()/fatal skip finally — confine exit to the outermost layer. Destructors are non-deterministic (GC time, never for registry-held objects) — finally is the only known-point release.