Designing Reliable Failure Boundaries in PHP: Errors, Exceptions & Result Types
This article explores how to design robust failure boundaries in PHP payment systems by distinguishing validation errors, expected business declines, transient provider failures, and programming defects, using result types for normal outcomes, translated exceptions for integration failures, idempotent retries, and observability without sensitive data.
Introduction
Most payment code looks simple on the happy path: validate amount, call provider, save attempt record, return response. The real difficulty is handling cases where the flow cannot complete: missing payment method, card declined, provider unavailable, illegal provider response, or a TypeError in our own code. These are all failures, but they are not the same kind of failure .
Treating them all as exceptions leads to catching overly broad types and turning every problem into a vague error. Turning them all into false, null, or arrays with a status key loses important meaning before the caller can make a correct decision.
The useful question is not "should we use exceptions or result types?" but:
Which layer of the application owns this failure? What can it meaningfully do next?
This article builds a partner payment workflow in pure PHP, distinguishing invalid input, expected business declines, transient provider faults, and programming defects. Each failure is translated only at the boundary that owns its presentation, contracts are tested, and operational paths are made visible without logging payment data.
One Payment, Four Different Failures
Consider a seemingly "defensive" function that actually creates dangerous failure boundaries:
function capturePayment(PaymentGateway $gateway, PaymentAttempt $attempt): array {
try {
$outcome = $gateway->capture($attempt);
return [
'status' => 201,
'body' => $outcome,
];
} catch (Throwable $exception) {
error_log($exception->getMessage());
return [
'status' => 422,
'body' => ['message' => 'Payment failed.'],
];
}
}This function collapses several distinct situations into a single 422 response:
Missing payment method ID in input.
Customer has insufficient funds.
Partner API times out.
Our code passes a string where an integer amount is expected.
These need different handling. The first should be rejected before the payment use case runs. The second is an expected business outcome the caller can interpret and act on. The third may be retryable and should become a 503 response or background recovery. The fourth is a defect that needs alerting and a safe generic 500 response, not telling the customer to "try another card".
The workflow has four failure boundaries:
Untrusted Input Payment Application Partner Provider
───────────────── ────────────────── ────────────────
Invalid field ────▶ Validation result ──────▶ Never enters payment flow
│
▼
Business decision ──▶ Capture success or decline
│
▼
Transport fault ────▶ Retry or recover later
│
▼
Programming defect ─▶ Report and stop safelyThe diagram is intentionally simple: failures should become more specific as they move toward the application center. At the outer boundary, input is just untrusted data. Inside the payment workflow, a decline is a meaningful decision. At the provider boundary, a timeout is a transport problem. A type error is neither a payment decision nor a transport problem—it signals our code violated its contract.
Failure Boundaries Are Design
Before naming exceptions, write down what each failure means. For our partner payment, these distinctions are useful:
Missing or malformed input is expected and belongs to the API client or UI. Return a validation result with field errors (e.g., 422 Unprocessable Entity). Do not enter the payment flow at all.
Card or payment method declined is also expected but is a business outcome. Return a result type so the client or calling workflow can choose the next step. A web endpoint might return 422; a CLI command might print the decline reason and return a known exit code.
Product not payable is another business rule. Use a result type when the caller can continue with an alternative; use a domain exception when the current operation must stop. Neither should be retried.
Timeout, rate limit, or provider unavailable may be transient. Represent with a retryable exception so a background worker or recovery workflow can deliberately retry, eventually presenting a safe 503 and alerting when attempts are exhausted.
Invalid provider credentials or provider contract change are engineering problems, not customer problems. They need non-retryable exceptions, a generic 500 presentation, and operational alerts.
TypeError , missing method, or broken invariant are programming defects. Let Error propagate to the process boundary where it can be reported and safely rendered. Do not turn it into a payment decline.
These are not generic status codes. A web endpoint, CLI command, and scheduled worker will not present the same result in the same way. The key decision is ownership .
For example, a card decline at the payment boundary is usually expected. Calling a network API to ask if a card can be charged is still part of the business flow. A timeout is different: we cannot claim the payment failed because a timeout only means we did not receive a response .
When money is involved, this distinction matters critically. A timeout is not evidence the provider did nothing. The provider may have already charged the card, but the response was lost on the way back. Blindly retrying with a new identifier could double-charge the customer.
PHP Has More Than Exception
PHP represents every throwable value through the Throwable interface. The two main branches are Exception and Error:
Throwable
├── Exception
│ ├── RuntimeException
│ ├── InvalidArgumentException
│ └── Application and library exceptions
└── Error
├── TypeError
├── ValueError
├── AssertionError
└── Engine and programming errors Exceptionis the branch we normally use to describe interrupted operations. Provider unavailable, unreadable import file, violated domain rule—these can be represented as exceptions. Error represents lower-level failures usually caused by illegal code or broken runtime contracts. Passing the wrong type to a strictly typed method yields TypeError. Calling an undefined method yields Error. These implement Throwable but should not be turned into ordinary business behavior.
That is why these two catches mean completely different things:
try {
$gateway->capture($attempt);
} catch (Exception $exception) {
// Handles Exception and its subclasses, but not Error.
} try {
$gateway->capture($attempt);
} catch (Throwable $throwable) {
// Handles both Exception and Error.
} catch (Throwable)is occasionally useful at a true process boundary. A command runner can use it to hand the throwable to a centralized error renderer. PHP also provides set_exception_handler() for uncaught throwables reaching the top of the process.
But inside controllers, application services, domain objects, or provider adapters, it is almost never the right choice. Those layers should only catch the exception types they understand. Catching Throwable around a payment attempt and returning false makes a TypeError look exactly like a legitimate decline.
Also do not use exception messages as error codes. Messages are for humans, may be translated, and change during routine maintenance. Class names, stable domain reasons, or dedicated properties should carry the information for branching decisions.
Result Types for Normal Alternative Outcomes
PHP lacks a native algebraic result type like Rust's Result or Swift's Result. But we can model a small, explicit set of normal outcomes with an interface and a few focused value objects.
For a capture operation, both success and provider decline are normal alternative outcomes. In both cases the caller needs to decide what to do next, so returning a value is clearer than throwing an exception for a decline:
interface PaymentOutcome { }
final readonly class PaymentCaptured implements PaymentOutcome {
public function __construct(
public string $providerPaymentId
) {}
}
final readonly class PaymentDeclined implements PaymentOutcome {
public function __construct(
public string $reason
) {}
}
final readonly class PaymentDetails {
public function __construct(
public int $amountInCents,
public string $currency,
public string $paymentMethodId
) {}
}
final readonly class PaymentAttempt {
public function __construct(
public string $id,
public PaymentDetails $payment
) {}
}
interface PaymentGateway {
public function capture(PaymentAttempt $attempt): PaymentOutcome;
}The type signature tells every caller: a successful method call has two possible business outcomes. We cannot accidentally ignore a decline—there is no null value to forget to check, and no magic false that could be confused with an implementation failure.
An application service can make both paths explicit:
interface PaymentAttempts {
public function start(PaymentDetails $payment): PaymentAttempt;
public function markCaptured(string $attemptId, string $providerPaymentId): void;
public function markDeclined(string $attemptId, string $reason): void;
public function markFailed(string $attemptId, string $reason): void;
}
final readonly class CapturePayment {
public function __construct(
private PaymentGateway $gateway,
private PaymentAttempts $attempts
) {}
public function handle(PaymentDetails $payment): PaymentOutcome {
$attempt = $this->attempts->start($payment);
$outcome = $this->gateway->capture($attempt);
if ($outcome instanceof PaymentCaptured) {
$this->attempts->markCaptured(
$attempt->id,
$outcome->providerPaymentId
);
return $outcome;
}
$this->attempts->markDeclined($attempt->id, $outcome->reason);
return $outcome;
}
}This code assumes PaymentOutcome has only these two implementations. PHP cannot enforce a sealed interface, so do not let this set grow accidentally. When a third outcome becomes useful, add it deliberately, update every presentation layer, and decide whether it is a normal alternative or an exceptional interruption.
Result types have trade-offs:
They make expected alternatives visible in method signatures and tests.
They keep ordinary control flow out of try / catch blocks.
Code becomes noisy when every small helper returns another wrapper.
They do not replace exceptions for I/O failures, missing configuration, or situations the caller cannot reasonably handle in the same flow.
Use a result when the caller has a meaningful next action. Use an exception when normal work cannot continue and control must leave the current path.
Translate Provider Failures at the Boundary
Provider integrations speak their own language: connection failures, response codes, provider-specific payloads. The rest of our application should not need to know those details.
The provider adapter is the correct place to translate that language into payment language. First, define the exceptions that cross this boundary:
final class PaymentProviderUnavailable extends RuntimeException {
public function __construct(
public readonly string $provider,
Throwable $previous
) {
parent::__construct(
message: "Payment provider [{$provider}] is unavailable.",
previous: $previous
);
}
public function logContext(): array {
return ['provider' => $this->provider];
}
}
final class PaymentProviderRequestFailed extends RuntimeException {
public function __construct(
public readonly string $provider,
Throwable $previous
) {
parent::__construct(
message: "Payment provider [{$provider}] rejected the integration request.",
previous: $previous
);
}
}Both exceptions preserve the original throwable as previous. This is exception translation , not exception erasure. Our logs and error trackers still see the transport failure, while application code can catch PaymentProviderUnavailable without importing the provider's library.
The added context is deliberately small. Provider name and attempt ID help operators diagnose integration issues. Raw request headers, authorization tokens, full request bodies, card data, and provider responses copied into exception messages must not appear in application logs.
Hide the concrete transport implementation behind a small provider-facing contract so the payment boundary stays pure PHP:
enum PartnerCaptureStatus {
case Captured;
case Declined;
case RetryableFailure;
case RequestFailure;
}
final readonly class PartnerCaptureResponse {
public function __construct(
public PartnerCaptureStatus $status,
public ?string $providerPaymentId = null,
public ?string $declineReason = null
) {}
}
final class PartnerConnectionFailed extends RuntimeException { }
interface PartnerPaymentApi {
public function capture(PaymentAttempt $attempt): PartnerCaptureResponse;
}This interface is intentionally provider-specific. The code executing the HTTP request maps the provider's response into PartnerCaptureResponse. The rest of our application sees only the stable payment contract:
final readonly class PartnerPaymentGateway implements PaymentGateway {
public function __construct(
private PartnerPaymentApi $api
) {}
public function capture(PaymentAttempt $attempt): PaymentOutcome {
try {
$response = $this->api->capture($attempt);
} catch (PartnerConnectionFailed $exception) {
throw new PaymentProviderUnavailable('partner-pay', $exception);
}
return match ($response->status) {
PartnerCaptureStatus::Captured => new PaymentCaptured(
$response->providerPaymentId ??
throw new PaymentProviderRequestFailed(
'partner-pay',
new RuntimeException('Partner Pay omitted the payment ID.')
)
),
PartnerCaptureStatus::Declined => new PaymentDeclined(
$response->declineReason ?? 'payment_declined'
),
PartnerCaptureStatus::RetryableFailure => throw new PaymentProviderUnavailable(
'partner-pay',
new RuntimeException('Partner Pay reported a temporary failure.')
),
PartnerCaptureStatus::RequestFailure => throw new PaymentProviderRequestFailed(
'partner-pay',
new RuntimeException('Partner Pay rejected the integration request.')
),
};
}
}The concrete mapping belongs to the provider adapter. Another provider might use status codes, a field in a success response, or a provider-specific exception to signal a decline. Keep that vocabulary at the edge. Return our stable PaymentDeclined value to the rest of the application.
One detail often missed: every call should use the stable ID of the attempt record as the provider's idempotency key. The transport code is responsible for how to send that value, but the application layer owns the value itself. We only retry a payment when the provider documents that repeated calls with the same key return or converge to the same charge.
Retries Need an Idempotency Contract
Retries themselves do not make payments safe. Idempotency contracts make retries safe.
Before calling the provider, create a persisted payment attempt record with an identifier like payment_attempt_01J.... Use that identifier as the provider's idempotency key. Persist the final provider payment ID when it becomes known.
Then define what to do in each uncertain state:
Payment attempt persisted
│
▼
Send provider request with stable idempotency key
│
├── Received decline ────────────────▶ Mark attempt declined
├── Received capture ────────────────▶ Mark attempt captured
└── Timeout or connection lost ──────▶ Retry with same key or query providerIf a timeout occurs after the provider has already charged, the next call with the same key must not create a second charge. Some providers offer a query-by-idempotency-key or query-by-payment-ID endpoint. Use it when provider documentation requires reconciliation instead of repeating the charge request.
A background worker can retry only the exceptions that signal transient provider faults. This small example uses sleep() to make the strategy visible. In production, a process manager or job system should schedule the next attempt rather than having a worker sit and wait:
final readonly class RetryPaymentAttempt {
private const array BACKOFF_SECONDS = [0, 30, 120, 600];
public function __construct(
private PaymentGateway $gateway,
private PaymentAttempts $attempts
) {}
public function handle(PaymentAttempt $attempt): PaymentOutcome {
$lastException = null;
foreach (self::BACKOFF_SECONDS as $delay) {
if ($delay > 0) {
sleep($delay);
}
try {
return $this->capture($attempt);
} catch (PaymentProviderRequestFailed $exception) {
$this->attempts->markFailed($attempt->id, 'provider_request_failed');
throw $exception;
} catch (PaymentProviderUnavailable $exception) {
$lastException = $exception;
}
}
$this->attempts->markFailed($attempt->id, 'provider_unavailable');
throw $lastException ?? new LogicException('A payment retry must fail with an exception.');
}
private function capture(PaymentAttempt $attempt): PaymentOutcome {
$outcome = $this->gateway->capture($attempt);
if ($outcome instanceof PaymentCaptured) {
$this->attempts->markCaptured($attempt->id, $outcome->providerPaymentId);
return $outcome;
}
$this->attempts->markDeclined($attempt->id, $outcome->reason);
return $outcome;
}
} PaymentProviderUnavailableis retried with bounded backoff. PaymentProviderRequestFailed fails immediately because bad credentials or a changed provider contract will not heal after a few quick retries. A normal PaymentDeclined is returned as a value because the worker has completed its business responsibility.
This example is intentionally small. A real worker must atomically record attempts, prevent two workers from concurrently processing the same payment attempt, and use a durable scheduler. But the failure model stays the same: only retry explicitly defined transient conditions, use the same idempotency key, and make the exhausted state visible.
Present Failures at the Outer Boundary
Input validation belongs before the payment application service. A pure PHP validator can turn untrusted input into a valid value or an explicit list of errors:
interface PaymentInputResult { }
final readonly class ValidPaymentInput implements PaymentInputResult {
public function __construct(public PaymentDetails $payment) {}
}
final readonly class InvalidPaymentInput implements PaymentInputResult {
public function __construct(public array $errors) {}
}
final class PaymentInputValidator {
public function validate(array $input): PaymentInputResult {
$errors = [];
$amount = filter_var($input['amount_in_cents'] ?? null, FILTER_VALIDATE_INT);
$currency = $input['currency'] ?? null;
$paymentMethodId = $input['payment_method_id'] ?? null;
if (!is_int($amount) || $amount < 1) {
$errors['amount_in_cents'] = 'The amount must be a positive integer.';
}
if (!is_string($currency) || preg_match('/^[A-Z]{3}$/', $currency) !== 1) {
$errors['currency'] = 'The currency must be a three-letter uppercase code.';
}
if (!is_string($paymentMethodId) || $paymentMethodId === '') {
$errors['payment_method_id'] = 'A payment method is required.';
}
if ($errors !== []) {
return new InvalidPaymentInput($errors);
}
return new ValidPaymentInput(
new PaymentDetails(
amountInCents: $amount,
currency: $currency,
paymentMethodId: $paymentMethodId
)
);
}
}The boundary can now turn the validation result and the payment outcome into HTTP-shaped arrays without leaking transport details into payment logic:
function capturePaymentEndpoint(
array $input,
PaymentInputValidator $validator,
CapturePayment $capturePayment
): array {
$validation = $validator->validate($input);
if ($validation instanceof InvalidPaymentInput) {
return [
'status' => 422,
'body' => ['errors' => $validation->errors],
];
}
try {
$outcome = $capturePayment->handle($validation->payment);
} catch (PaymentProviderUnavailable $exception) {
reportPaymentFailure($exception, 'unknown');
return [
'status' => 503,
'body' => ['message' => 'Payments are temporarily unavailable.'],
];
}
if ($outcome instanceof PaymentCaptured) {
return [
'status' => 201,
'body' => [
'status' => 'captured',
'payment_id' => $outcome->providerPaymentId,
],
];
}
return [
'status' => 422,
'body' => [
'status' => 'declined',
'reason' => $outcome->reason,
],
];
}The same CapturePayment service can be invoked from a CLI command without pretending it is HTTP. The command can print Payment declined: insufficient_funds and return a known exit code. It can let PaymentProviderUnavailable bubble to the command boundary, where the command logs the attempt ID and exits unsuccessfully, letting an operator or scheduler decide what to do next.
Notice what we did not add: no try/catch wrapper around every method. The endpoint owns the presentation of its normal PaymentOutcome. The process boundary owns the safe presentation of unhandled provider exceptions. This keeps both paths explicit without duplicating response code.
Wrap Only When Meaning Changes Across a Boundary
Wrapping every exception adds nothing. The snippet below adds no information and hides the original type from callers that already understand it:
try {
return $gateway->capture($attempt);
} catch (PaymentProviderUnavailable $exception) {
throw new PaymentProviderUnavailable('partner-pay', $exception);
}The gateway already expressed the payment-level meaning. The application service should let it pass through.
Wrapping is useful when an exception crosses into a different vocabulary. PartnerConnectionFailed is a transport concern; PaymentProviderUnavailable is a payment concern. A storage exception is a persistence concern; if the payment boundary needs to tell callers that recording the attempt failed, a PaymentAttemptCouldNotBeRecorded exception might be useful.
When translating, preserve the previous exception and add only the context the new layer owns:
throw new PaymentProviderUnavailable(
provider: 'partner-pay',
previous: $exception
);Avoid these patterns:
Catching Throwable in ordinary application code.
Turning every exception into false, null, or an empty collection.
Creating a new exception class for every provider status that has no business meaning.
Branching on exception messages.
Catching an exception just to log and re-throw, which can cause duplicate reporting.
Putting request bodies, tokens, card data, or provider error payloads into exception messages.
Retrying a payment because it "might be transient" without an idempotency contract.
A good exception hierarchy is small. It describes the failure modes a boundary needs to distinguish, not every line of code that might throw.
Test Failure Contracts
Failure handling is behavior. Test contracts at boundaries, not by asserting an internal catch block executed.
For the provider adapter, use a small in-memory fake. It returns provider responses and records the stable attempt IDs it received. Pest makes expected behavior obvious:
final class FakePartnerPaymentApi implements PartnerPaymentApi {
/** @var list */
public array $receivedAttemptIds = [];
public function __construct(
private PartnerCaptureResponse $response
) {}
public function capture(PaymentAttempt $attempt): PartnerCaptureResponse {
$this->receivedAttemptIds[] = $attempt->id;
return $this->response;
}
}
it('returns a decline for an expected provider rejection', function (): void {
$api = new FakePartnerPaymentApi(
new PartnerCaptureResponse(
status: PartnerCaptureStatus::Declined,
declineReason: 'insufficient_funds'
)
);
$gateway = new PartnerPaymentGateway($api);
$outcome = $gateway->capture(
new PaymentAttempt(
id: 'payment-attempt-123',
payment: new PaymentDetails(2_900, 'USD', 'payment-method-123')
)
);
expect($outcome)
->toBeInstanceOf(PaymentDeclined::class)
->and($outcome->reason)->toBe('insufficient_funds')
->and($api->receivedAttemptIds)->toBe(['payment-attempt-123']);
});The important assertion: a provider-declared decline becomes a PaymentDeclined value. It is not a retryable exception and does not become a generic server error.
Test the transport boundary separately with a fake that throws PartnerConnectionFailed. Assert the gateway translates it into PaymentProviderUnavailable and preserves the original throwable via getPrevious().
Then add tests that matter to the outside world:
Invalid input produces field errors and does not call the gateway.
Decline returns a documented outcome and does not report a provider fault.
Provider timeout returns a safe 503, logs attempt ID and provider name, but no sensitive data.
Background retry uses the same payment attempt ID on every provider call.
Non-retryable integration failure stops immediately without repeated calls.
Timeout after a fuzzy provider call reconciles with the same idempotency key before attempting a new charge.
Observability Without Sensitive Data
Exceptions give us type, message, previous throwable, stack trace, and any context we attach. This is only valuable when the context helps operators act without exposing customer data.
At the process boundary, log a small structured event:
function reportPaymentFailure(
PaymentProviderUnavailable $exception,
string $attemptId
): void {
error_log(json_encode([
'event' => 'payment.capture.failed',
'attempt_id' => $attemptId,
'exception' => $exception::class,
...$exception->logContext(),
], JSON_THROW_ON_ERROR));
}Log the event name, payment attempt ID, exception class, and provider. Allow the full previous throwable to be available to your error tracker safely, but do not build an observability strategy on copying sensitive strings into every log line.
Monitor counts of payment.capture.failed events, retry counts, pending attempt ages, and the oldest unreconciled attempt. Provider faults should become observable events, not a growing pile of stuck rows.
Practical Operational Checklist
Before shipping a payment integration, check these questions:
Can every payment attempt be identified by a persisted, stable ID?
Is that ID used as the idempotency key on every provider charge where supported?
Does the timeout path reconcile an existing provider charge instead of creating a new one?
Are validation failures rejected before calling the provider?
Are normal declines returned as explicit values, not broad exceptions?
Are only transient provider faults retried, with bounded backoff and a maximum attempt count?
Are invalid credentials and provider contract faults blocked from infinite retries?
Does the worker prevent concurrent processing of the same attempt?
Does exception reporting include attempt ID, provider, and safe correlation data—but no request bodies, headers, tokens, or payment details?
Can operators see failed attempts, retry counts, provider error rates, and the oldest unreconciled payment attempt?
Is there a documented fix-it command or runbook for reconciling fuzzy attempts?
Do tests cover the failure behaviors seen by customers, operators, and background workers?
If any answer is "no", adding another catch block will not make the system safer. What is missing is usually a boundary, a state transition, or an operational decision.
Conclusion
PHP errors, exceptions, and result types are not competing tools. They describe different kinds of information.
Use validation results at the edge for malformed input.
Use small result types for expected business alternatives the caller can act on.
Use translated exceptions for interrupted operations like provider unavailability.
Keep programming errors visible instead of disguising them as normal payment failures.
The partner payment adapter owns provider details and translates them into payment language. The application service owns payment attempt state. The endpoint or command owns presentation. The background worker owns delayed retry and recovery. The process boundary owns safe rendering and reporting of unhandled exceptions.
Start with one workflow that currently catches broad exceptions or returns vague booleans. Name its failure modes, decide which are normal alternatives, preserve context at integration boundaries, write tests for what each caller can do next. That small design step turns failure from an afterthought into a contract the rest of the application can trust.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Open Source Tech Hub
Sharing cutting-edge internet technologies and practical AI resources.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
