Centralize Third-Party API Error Handling with Custom Exceptions

📖 3 minutes read

When integrating multiple third-party APIs that serve similar purposes (payment gateways, shipping providers, SMS services), you’ll quickly notice they all handle errors differently. One throws CardException with a specific message, another returns error codes like INSUFFICIENT_FUNDS—same problem, completely different implementations.

Instead of scattering vendor-specific error handling throughout your Laravel codebase, wrap their exceptions in custom domain exceptions.

The Problem: Inconsistent Error Handling

// Payment provider 1 (Stripe)
try {
    $payment = $stripeClient->charge($amount);
} catch (\Stripe\Exception\CardException $e) {
    if (str_contains($e->getMessage(), 'insufficient funds')) {
        // Handle insufficient funds
    }
}

// Payment provider 2 (PayPal)
try {
    $payment = $paypalClient->charge($amount);
} catch (\PayPal\Exception\PayPalException $e) {
    if ($e->getCode() === 'INSUFFICIENT_FUNDS') {
        // Handle insufficient funds... differently
    }
}

// Every integration handles the same error differently

This approach doesn’t scale. Every time you add a new provider, you need to update error handling logic throughout your application. Testing becomes harder because you’re coupled to vendor exception types.

The Solution: Domain Exceptions with Adapters

Create custom exceptions that represent business scenarios (insufficient funds, declined payment, timeout) rather than vendor-specific errors:

// Define domain exceptions
namespace App\Exceptions\Payment;

class InsufficientFundsException extends PaymentException
{
    public function __construct(
        public readonly string $provider,
        public readonly string $transactionId,
        string $message = 'Insufficient funds to complete transaction'
    ) {
        parent::__construct($message);
    }
}

class PaymentDeclinedException extends PaymentException { /* ... */ }
class PaymentTimeoutException extends PaymentException { /* ... */ }

Now wrap vendor exceptions in adapter classes that implement a common interface:

class StripeAdapter implements PaymentGateway
{
    public function charge(Order $order): Transaction
    {
        try {
            return $this->client->createCharge([
                'amount' => $order->total,
                'currency' => $order->currency,
            ]);
        } catch (\Stripe\Exception\CardException $e) {
            // Map Stripe errors to domain exceptions
            if (str_contains($e->getMessage(), 'insufficient funds')) {
                throw new InsufficientFundsException(
                    provider: 'stripe',
                    transactionId: $e->getRequestId()
                );
            }
            
            throw PaymentException::fromVendorException('stripe', $e);
        }
    }
}

class PayPalAdapter implements PaymentGateway
{
    public function charge(Order $order): Transaction
    {
        try {
            return $this->client->createPayment([/* ... */]);
        } catch (\PayPal\Exception\PayPalException $e) {
            // Map PayPal errors to same domain exceptions
            if ($e->getCode() === 'INSUFFICIENT_FUNDS') {
                throw new InsufficientFundsException(
                    provider: 'paypal',
                    transactionId: $e->getCorrelationId()
                );
            }
            
            throw PaymentException::fromVendorException('paypal', $e);
        }
    }
}

Now Handle All Providers Consistently

class CheckoutService
{
    public function processPayment(Order $order, PaymentGateway $gateway)
    {
        try {
            $transaction = $gateway->charge($order);
            $order->markAsPaid($transaction);
        } catch (InsufficientFundsException $e) {
            // Same handler for ALL providers
            $this->notifyUser($order->user, 'payment_failed_insufficient_funds');
            $this->logPaymentFailure($e->provider, $e->transactionId);
        } catch (PaymentDeclinedException $e) {
            // ...
        }
    }
}

Why This Matters

  • Consistent interface: Your application logic doesn’t care which payment provider is being used
  • Easy to add providers: New integrations just need to implement the PaymentGateway interface and map their errors
  • Easy to switch providers: Change providers without touching your application logic
  • Better testing: Mock domain exceptions instead of vendor-specific ones
  • Provider-specific data: Custom exceptions can carry provider IDs, transaction IDs, and other metadata while presenting a unified interface

This pattern works for any multi-provider integration: shipping APIs, SMS services, email providers—anything where you need to abstract vendor differences behind a common interface.

Daryle De Silva

VP of Technology

11+ years building and scaling web applications. Writing about what I learn in the trenches.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *