PHP try/finally — kiedy finally naprawdę się wykonuje (i kiedy potrafi zaskoczyć)
Jak działa blok finally w PHP, do czego go używać i jakie pułapki kryją return oraz wyjątki w finally.
Problem
Masz kod, który zajmuje jakiś zasób — uchwyt pliku, połączenie z bazą, blokadę (lock), transakcję — i musisz go zwolnić niezależnie od tego, czy operacja się powiodła, czy rzuciła wyjątek. Bez finally sprzątanie ląduje w dwóch miejscach: w ścieżce sukcesu i w każdym catch. Łatwo o tym zapomnieć w jednym z nich, a wtedy zostają wiszące połączenia albo niezdjęte blokady.
$fh = fopen('/tmp/raport.csv', 'w');
try {
fwrite($fh, generujRaport());
} catch (RuntimeException $e) {
logBłąd($e);
fclose($fh); // duplikat #1
throw $e;
}
fclose($fh); // duplikat #2 — łatwo zapomnieć
finally rozwiązuje to jednym blokiem, który wykona się zawsze.
finally w pigułce
$fh = fopen('/tmp/raport.csv', 'w');
try {
fwrite($fh, generujRaport());
} catch (RuntimeException $e) {
logBłąd($e);
throw $e;
} finally {
fclose($fh); // wykona się ZAWSZE
}
Blok finally wykona się:
- gdy
tryzakończy się normalnie, - gdy zostanie złapany wyjątek w
catch, - gdy wyjątek nie zostanie złapany i poleci dalej w górę,
- gdy w
trylubcatchjestreturn(o tym za chwilę).
Klasyczne zastosowania
Transakcja w bazie
$pdo->beginTransaction();
try {
$pdo->prepare('UPDATE konta SET saldo = saldo - ? WHERE id = ?')
->execute([$kwota, $zNadawcy]);
$pdo->prepare('UPDATE konta SET saldo = saldo + ? WHERE id = ?')
->execute([$kwota, $doOdbiorcy]);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
Tu akurat finally nie jest konieczne — commit i rollBack są w rozłącznych ścieżkach. Ale gdy masz dodatkowy zasób do zwolnienia (np. blokadę), finally jest idealne:
$lock->acquire();
try {
przetwórzKolejkę();
} finally {
$lock->release(); // blokada zdjęta nawet przy wyjątku
}
Pułapka #1 — return w finally nadpisuje wszystko
To najczęstsze źródło zaskoczeń. return w finally wygrywa z każdym return z try i catch:
function test(): string
{
try {
return 'try';
} finally {
return 'finally'; // to zostanie zwrócone
}
}
echo test(); // finally
Co gorsza — return w finally połyka wyjątek, który właśnie leciał:
function test(): string
{
try {
throw new RuntimeException('coś się zepsuło');
} finally {
return 'wszystko ok'; // wyjątek znika bez śladu
}
}
echo test(); // wszystko ok — a błąd przepadł
Pułapka #2 — kolejność a wartość zwracana
Gdy try ma return, PHP najpierw wylicza wartość do zwrócenia, potem wykonuje finally, a dopiero potem faktycznie zwraca. Modyfikacja zmiennej w finally nie zmieni już zwróconej wartości skalarnej:
function licz(): int
{
$x = 1;
try {
return $x; // wartość 1 jest "zamrożona" tutaj
} finally {
$x = 99; // za późno — nie wpływa na zwrot
}
}
echo licz(); // 1
Inaczej jest z obiektami — zwracasz referencję, więc zmiana stanu obiektu w finally jest widoczna na zewnątrz:
function build(): ArrayObject
{
$obj = new ArrayObject(['stan' => 'start']);
try {
return $obj;
} finally {
$obj['stan'] = 'po finally'; // ta zmiana przejdzie
}
}
print_r(build());
// ArrayObject Object ( [storage:...] => Array ( [stan] => po finally ) )
Pułapka #3 — exit/die i błędy fatalne
finally to nie magiczny gwarant. Nie wykona się, gdy w try zrobisz exit() / die() albo dojdzie do prawdziwego błędu fatalnego (np. wyczerpania pamięci):
try {
echo "start\n";
exit('koniec');
} finally {
echo "to się NIE wykona\n";
}
$php test.phpstartkoniec
Co się dzieje pod spodem
Mentalny model jest prosty: w momencie wyjścia z try (czy to przez return, czy przez wyjątek) PHP zapamiętuje "co miało się stać" — jaką wartość zwrócić albo jaki wyjątek propagować — następnie uruchamia finally, a na końcu wznawia zapamiętaną akcję. Jeśli jednak finally samo wykona return lub throw, to zastępuje zapamiętaną akcję — stąd biorą się pułapki #1.
Praktyczna zasada na koniec: w finally trzymaj wyłącznie kod sprzątający (fclose, release, rollBack, zamknięcie połączenia). Żadnych return, żadnych throw, żadnej logiki biznesowej. Wtedy finally robi dokładnie to, czego od niego oczekujesz — i nic więcej.