Introduction
Most payment code looks simple on the happy path. We validate an amount, call a provider, save a payment attempt, and return a response.
The difficult part is deciding what happens when that flow does not complete. A missing payment
method, a declined card, an unavailable provider, an invalid provider response, and a TypeError
inside our own code are all failures. They are not, however, the same kind of failure.
If we treat them all as exceptions, our endpoint code eventually catches broad types and turns every
problem into a vague error. If we turn them all into false, null, or an array with a status key,
the important meaning disappears before the caller can make the right decision.
The useful question is not "should we use exceptions or result types?" The useful question is:
Which part of the application owns this failure, and what can it meaningfully do next?
In this article, we will build a partner-payment workflow using only plain PHP. We will separate invalid input, expected business rejections, transient provider failures, and programming defects. Then we will translate each failure only at the boundary that owns its presentation, test the contracts, and make the operational path visible without putting payment data in our logs.
One Payment, Four Different Failures
Let's start with code that looks defensive but creates a dangerous failure boundary:
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 has collapsed several distinct situations into one 422 response:
- The input is missing a payment method ID.
- The customer has insufficient funds.
- The partner API timed out.
- Our code passed a string where an integer amount was required.
Those situations need different behavior. The first should be rejected before the payment use case
runs. The second is an expected business outcome that a caller can explain and act on. The third may
be retryable and should become a 503 response or background recovery. The fourth is a defect that
needs an alert and a safe generic 500 response, not a message telling the customer to try another
card.
Our workflow has four failure boundaries:
untrusted input Payment application Partner provider
─────────────── ─────────────────── ────────────────
invalid fields ───────▶ validation result ───────▶ do not enter payment flow
│
▼
business decision ───────▶ captured or declined
│
▼
transport failure ───────▶ retry or recover later
│
▼
programming defect ──────▶ report and stop safely
The diagram is deliberately simple: failures should become more specific as they move toward the center of our application. 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 is a signal that our code has violated a contract.
The Failure Boundary Is the Design
Before naming exceptions, write down what each kind of failure means. For our partner payment, the following distinctions are useful:
Missing or malformed input is expected and belongs to the API client or UI. Return a validation
result with field errors, such as a 422 Unprocessable Entity response. Do not enter the payment
flow at all.
A declined card or payment method is also expected, but it is a business outcome. Return a result
type so the customer 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.
A product that cannot be paid for is another business rule. Use a result type when the caller can continue with an alternative, or a domain exception when the current operation must stop. Neither case should be retried.
A timeout, rate limit, or unavailable provider may be transient. Represent it with a retryable
exception so a background worker or recovery workflow can retry deliberately, eventually present a
safe 503, and alert when attempts are exhausted.
Invalid provider credentials or a changed provider contract are problems for the engineering
team, not the customer. They need a non-retryable exception, a generic 500 presentation, and an
operator alert.
A TypeError, missing method, or broken invariant is a programming defect. Let the Error
propagate to the process boundary, where it can be reported and rendered safely. Do not turn it into
a payment decline.
These are not universal status codes. A web endpoint, a CLI command, and a recurring worker do not all present the same result in the same way. The important decision is ownership.
For example, a card decline is often expected at the payment boundary. Calling a network API to ask whether a card can be charged is still part of the business flow. A timeout is different: we cannot claim that the payment failed because a timeout only says that we did not receive a response.
That distinction matters a lot when money is involved. A timeout is not evidence that the provider did nothing. The provider might have captured the payment and lost the response on its way back to us. Retrying blindly with a new identifier can charge the customer twice.
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
Exception is the branch we normally use to describe an interrupted operation. An unavailable
provider, unreadable import file, or violated domain rule may be represented with an exception when
Error represents a lower-level failure, often caused by invalid code or a broken runtime contract.
Passing the wrong type to a strictly typed method can cause a TypeError. Calling an undefined
method causes an Error. These failures implement Throwable, but they should not be converted
into ordinary business behavior.
This is why these two catches have very different meanings:
try {
$gateway->capture($attempt);
} catch (Exception $exception) {
// Handles Exception and its subclasses, but not Error.
}
try {
$gateway->capture($attempt);
} catch (Throwable $throwable) {
// Handles Exception and Error.
}
catch (Throwable) is occasionally useful at a true process boundary. A command runner can use it
throwable to one central error renderer. PHP also provides set_exception_handler() for an uncaught
throwable that reaches the top of a process.
It is almost never the right choice inside an endpoint, application service, domain object, or
provider adapter. Those layers should catch only the exception types they understand. Catching
Throwable around a payment attempt and returning false makes a TypeError look exactly like a
should.
Do not use an exception's message as an error code either. Messages are written for people, may be translated, and change during normal maintenance. The class, a stable domain reason, or a dedicated property should carry the branching information.
Result Types Are for Normal Alternatives
PHP does not have native algebraic result types such as Rust's Result or Swift's Result. We can
still model a small, explicit set of normal outcomes with an interface and focused value objects.
For a capture operation, success and a provider-declared decline are both normal alternatives. The caller needs to decide what to do next in both cases, so returning a value is clearer than throwing an exception for the 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 that a successful method call has two possible business
outcomes. We cannot accidentally ignore a decline because there is no null value to forget to
check and no magic false that could be confused with an implementation failure.
The application service can make the two 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 that an
interface is closed, so do not let the set grow casually. When a third outcome becomes useful, add
it deliberately, update every presenter, and decide whether it is really a normal alternative or an
interruption that should be an exception.
Result types have trade-offs too:
- They make expected alternatives visible in method signatures and tests.
- They keep ordinary control flow out of
tryandcatchblocks. - They can become noisy when every small helper returns another wrapper.
- They do not replace exceptions for broken I/O, missing configuration, or conditions 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
A provider integration speaks its own language: connection failures, response codes, and provider-specific payloads. The rest of our application should not need to know those details.
The provider adapter is the right place to translate that language into payment language. First, let's 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,
);
}
/** @return array<string, string> */
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 tracker can still show the transport failure, while
application code can catch PaymentProviderUnavailable without importing a provider library.
The added context is intentionally small. A provider name and attempt ID help an operator diagnose an integration. Raw request headers, authorization tokens, full request bodies, card data, and a provider response copied into an exception message do not belong in application logs.
Keep the concrete transport implementation behind a small provider-facing contract so the payment boundary stays plain 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 that performs an HTTP request can map
the provider's response into PartnerCaptureResponse. The rest of our application only sees 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 exact mapping belongs to the provider adapter. Another provider may use a status code, a field
inside a successful response, or a provider-specific exception to represent a decline. Keep that
vocabulary at the edge. Return our stable PaymentDeclined value to the rest of the application.
One detail is easy to overlook: every call should use the attempt's stable ID as the provider's idempotency key. The transport code owns how it sends that value, but the application owns the value. We only retry a payment when the provider documents that repeated calls with the same key return or converge on the same charge.
Retries Need an Idempotency Contract
Retries do not make a payment safe. An idempotency contract makes a retry safe.
Before calling the provider, create a durable payment-attempt record with an identifier such as
payment_attempt_01J.... Use that identifier as the provider idempotency key. Persist the final
provider payment ID when it is known.
Then define what happens for each uncertain state:
payment attempt persisted
│
▼
provider request sent with stable idempotency key
│
├── decline received ───────────────▶ mark attempt declined
├── capture received ───────────────▶ mark attempt captured
└── timeout or connection loss ─────▶ retry or query provider by the same key
If the timeout happens after the provider captures the payment, the next call with the same key must not create a second capture. Some providers offer a lookup endpoint for the idempotency key or their payment ID. Use it when the provider's documentation requires reconciliation instead of a repeated capture request.
A background worker can retry only the exception that represents a temporary provider failure. This
small example uses sleep() to make the policy visible. In production, a process supervisor or job
system should schedule the next attempt rather than hold a worker idle:
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;
}
}
PaymentProviderUnavailable is retried with bounded backoff. PaymentProviderRequestFailed fails
immediately because bad credentials or a changed provider contract will not improve after several
quick retries. A normal PaymentDeclined is returned as a value because the worker completed its
business responsibility.
The example is deliberately small. A real worker must record attempts atomically, prevent two workers from processing the same payment attempt concurrently, and use a durable scheduler. The failure model still stays the same: retry only a well-defined transient condition, with the same idempotency key, and make exhaustion visible.
Present Failures at the Outer Boundary
Input validation belongs before the payment application service. A plain 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
{
/** @param array<string, string> $errors */
public function __construct(
public array $errors,
) {}
}
final class PaymentInputValidator
{
/** @param array<string, mixed> $input */
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 payment outcome into an HTTP-shaped array without letting transport details leak into the 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 called from a CLI command without pretending it is HTTP. A
command can print Payment declined: insufficient_funds and return a known exit code. It can let a
PaymentProviderUnavailable exception reach the command boundary, where the command logs the
attempt ID and exits with failure so an operator or scheduler can decide what happens next.
Notice what we did not add: a try/catch block around every method. The endpoint owns the
presentation of its normal PaymentOutcome. A process boundary owns the safe presentation of an
unhandled provider exception. This keeps the two paths explicit without repeating response code.
Wrap Only When the Boundary Changes Meaning
Wrapping every exception is not useful. This adds no information and hides the original type from a caller that already understands 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.
Wrapping is useful when an exception crosses into a different vocabulary. PartnerConnectionFailed
is a transport concern; PaymentProviderUnavailable is a payment concern. A storage-layer exception
is a persistence concern; a PaymentAttemptCouldNotBeRecorded exception could be useful if a payment
boundary needs to communicate a durable-recording failure to its caller.
When translating, preserve the previous exception and add only the context that the new layer owns:
throw new PaymentProviderUnavailable(
provider: 'partner-pay',
previous: $exception,
);
Avoid these patterns:
- catching
Throwableinside ordinary application code - turning every exception into
false,null, or an empty collection - creating a new exception class for every provider status without a business meaning
- branching on an exception message
- catching an exception only to log it and throw it again, which can duplicate reports
- putting request bodies, tokens, card data, or provider error payloads in an exception message
- retrying a payment because a failure is "probably temporary" 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 can throw.
Test the Failure Contract
Failure handling is behavior. Test the contract at the boundaries instead of asserting that an
internal catch block ran.
For the provider adapter, use a small in-memory fake. It returns a provider response and records the stable attempt ID it received. Pest keeps the expected behavior easy to scan:
final class FakePartnerPaymentApi implements PartnerPaymentApi
{
/** @var list<string> */
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 is that a provider-declared decline becomes a PaymentDeclined value. It is
not a retryable exception and it does not become a generic server error.
Test the transport boundary separately with a fake that throws PartnerConnectionFailed. The
assertion should prove that the gateway translates it to PaymentProviderUnavailable and keeps the
original throwable as getPrevious().
Then add tests that matter for the outside world:
- Invalid input produces field errors and does not call the gateway.
- A decline returns the documented result without reporting a provider outage.
- A provider timeout returns a safe
503response and records an attempt ID and provider name, but no secrets. - A background retry uses the same payment attempt ID on every provider call.
- A non-retryable integration failure stops immediately without repeated calls.
- A timeout after an ambiguous provider call is reconciled by the same idempotency key before a new capture is attempted.
Observability Without Secrets
An exception gives us a type, message, previous throwable, stack trace, and any context we attach. That is valuable only when the context helps an operator act without exposing customer data.
At a process boundary, record 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));
}
The record includes an event name, the payment attempt ID, the exception class, and the provider. It full previous throwable available to your error tracker when it is safe to do so, but do not build an observability strategy around copying sensitive strings into every log line.
Monitor the number of payment.capture.failed events, retry count, age of pending attempts, and the
oldest unreconciled attempt. A provider outage should become an observable incident, not a slowly
growing collection of stuck rows.
A Practical Operational Checklist
Before shipping a payment integration, check these questions:
- Can every payment attempt be identified with a durable, stable ID?
- Does every provider capture use that ID as an idempotency key where the provider supports it?
- Does the timeout path reconcile an existing provider payment instead of creating a new charge?
- Are validation failures rejected before provider calls?
- Are normal declines returned as explicit values rather than broad exceptions?
- Are only transient provider failures retried, with bounded backoff and a maximum attempt count?
- Are invalid credentials and provider contract failures prevented from retrying forever?
- Does the worker prevent concurrent processing of the same attempt?
- Do exception reports include attempt ID, provider, and safe correlation data without request bodies, headers, tokens, or payment details?
- Can an operator see failed attempts, retry counts, provider error rate, and the oldest unreconciled payment attempt?
- Is there a documented repair command or runbook for reconciling an ambiguous attempt?
- Do tests cover the failure behavior the customer, operator, and background worker each see?
If the answer to any of these is no, adding another catch block will not make the system safer. The
missing piece 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 for malformed input at the edge. Use small result types for expected business alternatives that the caller can continue from. Use translated exceptions for interrupted operations such as unavailable providers. Let programming errors remain 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 the payment attempt state. The endpoint or command owns presentation. A background worker owns delayed retry and recovery. The process boundary owns safe rendering and reporting for unhandled exceptions.
Start with one workflow that currently catches a broad exception or returns a vague boolean. Name its failure modes, decide which ones are normal alternatives, preserve the context at the integration boundary, and 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.
I hope that you liked this article and if you do, don't forget to share this article with your friends!!! See ya!