Never swallow
The nightly renewal run has a catch (Throwable $e) around each customer, with a comment: one bad card mustn't stop everyone else renewing. That's a good reason, and it was fine for a year.
Then the payment gateway went down for an hour. Every charge timed out, every timeout was caught, and every customer was recorded as declined. In the morning, 2,000 people got an email saying their card had bounced. Not one card had been looked at.
A catch is a claim
Every catch says: I know what this failure means, and I know what to do about it. catch (CardDeclined $e) makes that claim about a declined card, and it's true: record it and move on. catch (Throwable $e) makes the same claim about everything that could ever go wrong, including a gateway outage, a TypeError, a typo in a method name and the database going away. It's never true.
A swallowed exception is worse than a crash. A crash is loud, has a stack trace, and stops before it does more damage. A swallow turns a bug into wrong data, quietly, and the first anyone hears of it is a customer.
Logging and carrying on is still swallowing. A log line nobody is paged for, next to 2,000 wrong records, is not handling.
Catch narrowly
So catch the specific failures you have a plan for, and give each one its own plan:
try { $chargeId = $this->payments->charge($subscription->customer, $subscription->pence); } catch (CardDeclined $e) { $report->addDecline($subscription->customer, $e->reason); continue; } catch (GatewayUnavailable $e) { throw RenewalRunAborted::after($report, $e); } $report->addRenewal($subscription->customer, $chargeId);
Notice how small the try is. Only charge() can decline a card, so only charge() goes inside. If addRenewal() were in there too, a mistake in it could still land in a catch that was never written for it.
Anything not caught here, from a TypeError to a full disk, goes up to code that knows more, or all the way to the framework's error handler, which logs it properly and shows an error page. That's the one place a catch-all belongs.
Wrap, and keep the cause
The outage can't be handled here, but the run knows something the gateway doesn't: who has already been charged. That's worth adding before passing it on. Throw your own exception, carrying that, with the original as its previous:
parent::__construct($message, previous: $previous);
The log shows both, "Renewals stopped after 412" caused by "Connection to payments.example.com timed out", and a retry can skip the 412.
Power-up
<?php foreach ($due as $subscription) { try { $chargeId = $this->payments->charge($subscription->customer, $subscription->pence); $report->addRenewal($subscription->customer, $chargeId); } catch (Throwable $e) { // An outage, a TypeError, a typo: all "declined" $report->addDecline($subscription->customer, $e->getMessage()); } }
<?php foreach ($due as $subscription) { try { $chargeId = $this->payments->charge($subscription->customer, $subscription->pence); } catch (CardDeclined $e) { $report->addDecline($subscription->customer, $e->reason); continue; } catch (GatewayUnavailable $e) { throw RenewalRunAborted::after($report, $e); // previous: $e } $report->addRenewal($subscription->customer, $chargeId); }