DEV Community

Cover image for The Adapter Pattern: A Laravel Developer's Guide to API Integration
Anas Hussain
Anas Hussain

Posted on

The Adapter Pattern: A Laravel Developer's Guide to API Integration

1. Hook & Problem Statement

You're building a Laravel application. You need to integrate with a third-party payment gateway.

You find a popular package with a clean API:

$payment = new PaymentGateway();
$payment->setApiKey('secret');
$payment->charge(100, 'USD');
Enter fullscreen mode Exit fullscreen mode

You integrate it across 20 different services and controllers. Everything works perfectly.

Then the payment gateway is acquired by another company. The new API is completely different:

$client = new PaymentClient(['api_key' => 'secret']);
$request = new ChargeRequest(['amount' => 100, 'currency' => 'USD']);
$response = $client->send($request);
Enter fullscreen mode Exit fullscreen mode

Now you have to update 20 files. You search your codebase for PaymentGateway and find references everywhere. It's a nightmare.

This is the problem with tight coupling to third-party APIs.

Third-party APIs change. They have complex APIs. They have inconsistent error handling. They have different authentication mechanisms.

The Adapter Pattern is designed to solve this problem. It wraps the third-party API in a consistent interface that your application can depend on.


2. Why This Pattern Exists

The Software Engineering Problem It Solves

The Adapter Pattern solves the problem of making incompatible interfaces work together. It acts as a bridge between your application's expected interface and a third-party API's actual interface.

The Pain That Existed Before

Before the Adapter Pattern (or in codebases that don't use it), developers faced:

  1. Tight Coupling: Application code was tightly coupled to third-party APIs.
  2. API Changes: A vendor API change required changes across the entire codebase.
  3. Inconsistent Interfaces: Different APIs had different patterns and signatures.
  4. Complex Initialization: APIs required complex setup and configuration.
  5. Error Handling Inconsistency: Each API threw different exceptions.
  6. Testing Difficulty: You couldn't easily mock or test third-party APIs.

Why Large Applications Need It

As applications grow, they integrate with more third-party services:

  • Payment gateways (Stripe, PayPal, Braintree)
  • Notification services (Twilio, SendGrid, Slack)
  • Storage providers (AWS S3, GCS, Azure)
  • CRM systems (Salesforce, HubSpot)
  • Analytics services (Google Analytics, Mixpanel)
  • Social media APIs (Twitter, Facebook, LinkedIn)

The Adapter Pattern provides:

  • Consistent Interface: Your application uses one interface for all services.
  • Encapsulation: API complexity is hidden behind the adapter.
  • Testability: Adapters can be mocked for testing.
  • Flexibility: Swap providers without changing your application code.
  • Open/Closed Compliance: Add new adapters without modifying existing code.

3. Real World Analogy

The Power Plug Adapter

Imagine you're traveling to a different country. You have a laptop that uses a Type A plug (two flat prongs). The country uses Type C sockets (two round prongs).

You can't connect your laptop directly. The interfaces are incompatible.

The Power Plug Adapter:
The adapter has a Type A socket on one side and Type C prongs on the other.

  • Type A side: Matches your laptop's plug (your application's interface).
  • Type C side: Matches the wall socket (the third-party API).
  • The Adapter: Converts the power (data/requests) between the two.

Analogy Mapping:

  • Laptop: Your application.
  • Power Plug (Type A): Your application's expected interface.
  • Wall Socket (Type C): The third-party API.
  • Power Adapter: The Adapter Pattern.
  • Traveler: The developer.
  • Different Countries: Different third-party APIs.

Why it works: You don't have to change your laptop or the wall socket. The adapter handles the conversion.


4. The Pain (Bad Design)

Let's look at a typical tight coupling to a third-party API.

namespace App\Services;

use Stripe\StripeClient;
use Stripe\Exception\CardException;
use Stripe\Exception\RateLimitException;
use Stripe\Exception\InvalidRequestException;
use Stripe\Exception\AuthenticationException;
use Stripe\Exception\ApiConnectionException;
use Stripe\Exception\ApiErrorException;
use Illuminate\Support\Facades\Log;

class PaymentService
{
    private StripeClient $stripe;

    public function __construct()
    {
        $this->stripe = new StripeClient(config('services.stripe.secret'));
    }

    public function processPayment(Order $order): array
    {
        try {
            // Stripe-specific API call
            $intent = $this->stripe->paymentIntents->create([
                'amount' => $order->total * 100,
                'currency' => $order->currency,
                'payment_method' => $order->payment_method_id,
                'confirmation_method' => 'manual',
                'confirm' => true,
                'metadata' => [
                    'order_id' => $order->id,
                    'customer_id' => $order->user_id,
                ],
                'statement_descriptor' => 'Order ' . $order->id,
                'description' => "Order #{$order->id}",
            ]);

            // Stripe-specific response handling
            if ($intent->status === 'requires_action') {
                return [
                    'status' => 'requires_action',
                    'client_secret' => $intent->client_secret,
                    'payment_intent_id' => $intent->id,
                ];
            }

            if ($intent->status === 'succeeded') {
                $order->status = 'paid';
                $order->payment_intent_id = $intent->id;
                $order->save();

                return [
                    'status' => 'succeeded',
                    'payment_intent_id' => $intent->id,
                ];
            }

            return [
                'status' => 'failed',
                'error' => "Unexpected status: {$intent->status}",
            ];

        } catch (CardException $e) {
            // Stripe-specific exception handling
            Log::error('Payment failed: Card error', [
                'error' => $e->getMessage(),
                'decline_code' => $e->getDeclineCode(),
                'order' => $order->id,
            ]);
            return ['status' => 'failed', 'error' => 'Card declined: ' . $e->getMessage()];

        } catch (RateLimitException $e) {
            Log::error('Payment failed: Rate limit', ['error' => $e->getMessage()]);
            return ['status' => 'failed', 'error' => 'Too many requests. Please try again later.'];

        } catch (InvalidRequestException $e) {
            Log::error('Payment failed: Invalid request', ['error' => $e->getMessage()]);
            return ['status' => 'failed', 'error' => 'Invalid payment request.'];

        } catch (AuthenticationException $e) {
            Log::error('Payment failed: Authentication error', ['error' => $e->getMessage()]);
            return ['status' => 'failed', 'error' => 'Payment service authentication failed.'];

        } catch (ApiConnectionException $e) {
            Log::error('Payment failed: Connection error', ['error' => $e->getMessage()]);
            return ['status' => 'failed', 'error' => 'Unable to connect to payment service.'];

        } catch (ApiErrorException $e) {
            Log::error('Payment failed: API error', ['error' => $e->getMessage()]);
            return ['status' => 'failed', 'error' => 'Payment service error.'];
        }
    }

    public function refundPayment(string $paymentIntentId, float $amount): array
    {
        try {
            // Stripe-specific refund
            $refund = $this->stripe->refunds->create([
                'payment_intent' => $paymentIntentId,
                'amount' => $amount * 100,
            ]);

            return [
                'success' => true,
                'refund_id' => $refund->id,
                'status' => $refund->status,
            ];

        } catch (\Exception $e) {
            Log::error('Refund failed', ['error' => $e->getMessage()]);
            return ['success' => false, 'error' => $e->getMessage()];
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Why This Is Terrible

  1. Tight Coupling: The service is tightly coupled to Stripe's API and exceptions.

  2. Complex Initialization: The Stripe client is created inside the constructor.

  3. Inconsistent Error Handling: Stripe-specific exceptions are handled directly.

  4. Duplication: If you add PayPal or other payment methods, you'll duplicate this logic.

  5. Difficult Testing: Testing requires mocking the Stripe client or making real API calls.

  6. Violates OCP: Adding a new payment method requires modifying this class.

  7. Violates DIP: The class depends on a concrete implementation (StripeClient).

Why Developers Write Code Like This

  • It's the "quick and dirty" approach.
  • They think "we'll only use Stripe."
  • They don't anticipate API changes.
  • They haven't learned the Adapter Pattern.

5. Solution Overview

The Adapter Pattern is a structural design pattern that allows objects with incompatible interfaces to work together. It acts as a bridge between two incompatible interfaces.

Core Idea

Instead of your application code directly calling the third-party API, you create an adapter that wraps the third-party API and exposes a consistent interface that your application can depend on.

Main Participants

  1. Target Interface: The interface that your application expects.
  2. Adapter: The class that implements the target interface and wraps the third-party API.
  3. Adaptee: The third-party API that you're adapting.
  4. Client: Your application code that uses the target interface.

How Objects Collaborate

Client → Target Interface ← Adapter → Adaptee (Third-Party API)
Enter fullscreen mode Exit fullscreen mode

The client calls the target interface. The adapter translates the calls to the adaptee's interface.

Mental Model

Think of a translator.

  • English Speaker: Your application code.
  • French Speaker: The third-party API.
  • Translator: The Adapter Pattern.
  • English Phrases: Your application's expected interface.
  • French Phrases: The third-party API's interface.

The translator converts English to French, allowing the two to communicate.

Benefits

  • Decoupling: Application code is decoupled from third-party APIs.
  • Consistency: All adapters implement the same interface.
  • Testability: Mock the adapter for testing.
  • Flexibility: Swap providers without changing application code.
  • Encapsulation: API complexity is hidden.

Trade-offs

  • More Classes: You need an adapter for each API.
  • Boilerplate: You need to write the adapter logic.
  • Indirection: More layers between your code and the API.

6. UML Diagram

Laravel Adapter Pattern Mermaid Diagram

Laravel Adapter Pattern Mermaid Diagram

Diagram Explanation

  1. PaymentInterface is the target interface that the application expects.
  2. StripeAdapter and PayPalAdapter implement the interface.
  3. Each adapter wraps its respective third-party client.
  4. PaymentService depends on the interface, not the concrete adapters.

7. Vanilla PHP Example

Let's refactor the payment service using the Adapter Pattern.

Before Refactoring

(The terrible code shown above)

After Refactoring

Step 1: Define the Target Interface
interface PaymentInterface
{
    public function charge(float $amount, string $currency, string $paymentMethodId): array;
    public function refund(string $paymentIntentId, float $amount): array;
}
Enter fullscreen mode Exit fullscreen mode
Step 2: Create the Stripe Adapter
class StripeAdapter implements PaymentInterface
{
    private \Stripe\StripeClient $stripe;
    private LoggerInterface $logger;

    public function __construct(string $secretKey, LoggerInterface $logger = null)
    {
        $this->stripe = new \Stripe\StripeClient($secretKey);
        $this->logger = $logger ?? new NullLogger();
    }

    public function charge(float $amount, string $currency, string $paymentMethodId): array
    {
        try {
            $intent = $this->stripe->paymentIntents->create([
                'amount' => $this->formatAmount($amount),
                'currency' => $currency,
                'payment_method' => $paymentMethodId,
                'confirmation_method' => 'manual',
                'confirm' => true,
            ]);

            $this->logger->info('Stripe charge successful', ['intent_id' => $intent->id]);

            return $this->formatResponse($intent);

        } catch (\Exception $e) {
            $this->logger->error('Stripe charge failed', ['error' => $e->getMessage()]);
            return $this->handleStripeException($e);
        }
    }

    public function refund(string $paymentIntentId, float $amount): array
    {
        try {
            $refund = $this->stripe->refunds->create([
                'payment_intent' => $paymentIntentId,
                'amount' => $this->formatAmount($amount),
            ]);

            $this->logger->info('Stripe refund successful', ['refund_id' => $refund->id]);

            return [
                'success' => true,
                'refund_id' => $refund->id,
                'status' => $refund->status,
            ];

        } catch (\Exception $e) {
            $this->logger->error('Stripe refund failed', ['error' => $e->getMessage()]);
            return [
                'success' => false,
                'error' => $this->getUserFriendlyMessage($e),
            ];
        }
    }

    private function formatAmount(float $amount): int
    {
        return (int)($amount * 100);
    }

    private function formatResponse($intent): array
    {
        return [
            'success' => $intent->status === 'succeeded',
            'status' => $intent->status,
            'payment_intent_id' => $intent->id,
            'client_secret' => $intent->client_secret ?? null,
            'error' => null,
        ];
    }

    private function handleStripeException(\Exception $e): array
    {
        $status = 'failed';
        $error = $this->getUserFriendlyMessage($e);

        // Map Stripe exceptions to friendly messages
        if ($e instanceof \Stripe\Exception\CardException) {
            $error = 'Card declined: ' . $e->getMessage();
        } elseif ($e instanceof \Stripe\Exception\RateLimitException) {
            $error = 'Too many requests. Please try again later.';
        } elseif ($e instanceof \Stripe\Exception\AuthenticationException) {
            $error = 'Payment service authentication failed.';
        } elseif ($e instanceof \Stripe\Exception\ApiConnectionException) {
            $error = 'Unable to connect to payment service.';
        }

        return [
            'success' => false,
            'status' => $status,
            'error' => $error,
        ];
    }

    private function getUserFriendlyMessage(\Exception $e): string
    {
        return 'Payment failed: ' . $e->getMessage();
    }
}
Enter fullscreen mode Exit fullscreen mode
Step 3: Create the PayPal Adapter
class PayPalAdapter implements PaymentInterface
{
    private \PayPal\Rest\ApiContext $apiContext;
    private LoggerInterface $logger;

    public function __construct(string $clientId, string $clientSecret, LoggerInterface $logger = null)
    {
        $this->apiContext = new \PayPal\Rest\ApiContext(
            new \PayPal\Auth\OAuthTokenCredential($clientId, $clientSecret)
        );
        $this->logger = $logger ?? new NullLogger();
    }

    public function charge(float $amount, string $currency, string $paymentMethodId): array
    {
        try {
            $payment = new \PayPal\Api\Payment();
            $payment->setIntent('sale')
                ->setPayer([
                    'payment_method' => 'paypal',
                    'payer_info' => ['payment_method_id' => $paymentMethodId],
                ])
                ->setTransactions([[
                    'amount' => [
                        'total' => number_format($amount, 2, '.', ''),
                        'currency' => $currency,
                    ],
                ]])
                ->setRedirectUrls([
                    'return_url' => 'http://example.com/return',
                    'cancel_url' => 'http://example.com/cancel',
                ]);

            $payment->create($this->apiContext);

            $this->logger->info('PayPal charge created', ['payment_id' => $payment->getId()]);

            // Get approval URL
            $approvalUrl = null;
            foreach ($payment->getLinks() as $link) {
                if ($link->getRel() === 'approval_url') {
                    $approvalUrl = $link->getHref();
                    break;
                }
            }

            return [
                'success' => true,
                'status' => 'pending',
                'payment_intent_id' => $payment->getId(),
                'approval_url' => $approvalUrl,
                'error' => null,
            ];

        } catch (\Exception $e) {
            $this->logger->error('PayPal charge failed', ['error' => $e->getMessage()]);
            return [
                'success' => false,
                'status' => 'failed',
                'error' => 'PayPal charge failed: ' . $e->getMessage(),
            ];
        }
    }

    public function refund(string $paymentIntentId, float $amount): array
    {
        try {
            $sale = new \PayPal\Api\Sale();
            $sale->setId($paymentIntentId);

            $refund = new \PayPal\Api\Refund();
            $refund->setAmount([
                'total' => number_format($amount, 2, '.', ''),
                'currency' => 'USD',
            ]);

            $refunded = $sale->refund($refund, $this->apiContext);

            $this->logger->info('PayPal refund successful', ['refund_id' => $refunded->getId()]);

            return [
                'success' => true,
                'refund_id' => $refunded->getId(),
                'status' => $refunded->getState(),
            ];

        } catch (\Exception $e) {
            $this->logger->error('PayPal refund failed', ['error' => $e->getMessage()]);
            return [
                'success' => false,
                'error' => 'Refund failed: ' . $e->getMessage(),
            ];
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
Step 4: The Refactored Service
class PaymentService
{
    private PaymentInterface $payment;

    public function __construct(PaymentInterface $payment)
    {
        $this->payment = $payment;
    }

    public function processPayment(Order $order): array
    {
        $result = $this->payment->charge(
            $order->total,
            $order->currency,
            $order->payment_method_id
        );

        if ($result['success'] && $result['status'] === 'succeeded') {
            $order->status = 'paid';
            $order->payment_intent_id = $result['payment_intent_id'];
            $order->save();
        }

        return $result;
    }

    public function refundPayment(string $paymentIntentId, float $amount): array
    {
        return $this->payment->refund($paymentIntentId, $amount);
    }
}
Enter fullscreen mode Exit fullscreen mode
Step 5: Usage
// In your service provider or controller
$adapter = new StripeAdapter(config('services.stripe.secret'));
$paymentService = new PaymentService($adapter);

$result = $paymentService->processPayment($order);

// Or with PayPal
$adapter = new PayPalAdapter(
    config('services.paypal.client_id'),
    config('services.paypal.secret')
);
$paymentService = new PaymentService($adapter);
Enter fullscreen mode Exit fullscreen mode

What We Improved

  1. Decoupling: PaymentService doesn't know about Stripe or PayPal.
  2. Consistent Interface: Both adapters implement PaymentInterface.
  3. Centralized Error Handling: API-specific errors are handled in the adapters.
  4. Testability: Mock PaymentInterface for testing.
  5. Open/Closed: Add a new payment method by creating a new adapter.
  6. Encapsulation: API complexity is hidden in the adapters.

8. Laravel Internal Example

Laravel uses the Adapter Pattern extensively. Let's look at some key examples.

Filesystem Adapters

Laravel's Filesystem uses adapters to support different storage providers.

// Illuminate\Contracts\Filesystem\Filesystem (Target Interface)
interface Filesystem
{
    public function exists(string $path): bool;
    public function get(string $path): string;
    public function put(string $path, $contents, $options = []): bool;
    public function delete(string|array $paths): bool;
    // ... more methods
}

// LocalFilesystemAdapter (Local)
class LocalFilesystemAdapter implements Filesystem
{
    private \League\Flysystem\Filesystem $flysystem;

    public function put(string $path, $contents, $options = []): bool
    {
        return $this->flysystem->write($path, $contents);
    }
    // ... implementation
}

// S3FilesystemAdapter (S3)
class S3FilesystemAdapter implements Filesystem
{
    private \League\Flysystem\AwsS3v3\AwsS3Adapter $adapter;

    public function put(string $path, $contents, $options = []): bool
    {
        return $this->adapter->write($path, $contents);
    }
    // ... implementation
}
Enter fullscreen mode Exit fullscreen mode

Why This Is Elegant:

  • The same Storage::disk('local')->put() works for all drivers.
  • Adding a new driver only requires creating a new adapter.
  • The filesystem API is consistent across providers.

Cache Adapters

Laravel's Cache system uses adapters.

// Illuminate\Contracts\Cache\Store (Target Interface)
interface Store
{
    public function get($key);
    public function put($key, $value, $seconds);
    public function increment($key, $value = 1);
    public function decrement($key, $value = 1);
    public function forever($key, $value);
    public function forget($key);
    public function flush();
}

// RedisStore (Adapter)
class RedisStore implements Store
{
    private \Illuminate\Redis\Connections\Connection $redis;

    public function put($key, $value, $seconds)
    {
        // Redis-specific implementation
    }
}

// MemcachedStore (Adapter)
class MemcachedStore implements Store
{
    private \Memcached $memcached;

    public function put($key, $value, $seconds)
    {
        // Memcached-specific implementation
    }
}
Enter fullscreen mode Exit fullscreen mode

Queue Adapters

Laravel's Queue system uses adapters.

// Illuminate\Contracts\Queue\Queue (Target Interface)
interface Queue
{
    public function push($job, $data = '', $queue = null);
    public function later($delay, $job, $data = '', $queue = null);
    public function pop($queue = null);
}

// DatabaseQueue (Adapter)
class DatabaseQueue implements Queue
{
    private DatabaseConnection $connection;

    public function push($job, $data = '', $queue = null)
    {
        // Database-specific implementation
    }
}

// RedisQueue (Adapter)
class RedisQueue implements Queue
{
    private Connection $redis;

    public function push($job, $data = '', $queue = null)
    {
        // Redis-specific implementation
    }
}

// SqsQueue (Adapter)
class SqsQueue implements Queue
{
    private SqsClient $sqs;

    public function push($job, $data = '', $queue = null)
    {
        // SQS-specific implementation
    }
}
Enter fullscreen mode Exit fullscreen mode

Mail Adapters

Laravel's Mail system uses adapters.

// Illuminate\Contracts\Mail\Mailer (Target Interface)
interface Mailer
{
    public function send($view, array $data = [], $callback = null);
    public function queue($view, array $data = [], $callback = null);
    public function raw($text, $callback = null);
}

// SmtpMailer (Adapter)
class SmtpMailer implements Mailer
{
    private \Swift_Mailer $swift;

    public function send($view, array $data = [], $callback = null)
    {
        // SMTP-specific implementation
    }
}

// MailgunMailer (Adapter)
class MailgunMailer implements Mailer
{
    private MailgunClient $client;

    public function send($view, array $data = [], $callback = null)
    {
        // Mailgun-specific implementation
    }
}
Enter fullscreen mode Exit fullscreen mode

Session Adapters

Laravel's Session system uses adapters.

// Illuminate\Contracts\Session\Session (Target Interface)
interface Session
{
    public function get($name, $default = null);
    public function put($key, $value);
    public function remove($key);
    public function all();
    public function flush();
}

// DatabaseSessionHandler (Adapter)
class DatabaseSessionHandler implements Session
{
    private DatabaseConnection $connection;

    public function put($key, $value)
    {
        // Database-specific implementation
    }
}

// FileSessionHandler (Adapter)
class FileSessionHandler implements Session
{
    private Filesystem $files;

    public function put($key, $value)
    {
        // File-specific implementation
    }
}
Enter fullscreen mode Exit fullscreen mode

9. Real Laravel Application Example

Let's build a Shipping Provider Integration using the Adapter Pattern.

Scenario

Your e-commerce application needs to integrate with multiple shipping providers:

  • FedEx: Good for domestic, reliable, but expensive.
  • UPS: Good for international, complex rates.
  • USPS: Good for small packages, cheap.
  • DHL: Good for expedited international.

Each provider has a different API, authentication, and response format.

Implementation

Step 1: Define the Target Interface
// app/Contracts/ShippingInterface.php
namespace App\Contracts;

use App\Models\Shipment;
use App\Models\Order;

interface ShippingInterface
{
    public function getRates(Order $order): array;
    public function createShipment(Shipment $shipment): array;
    public function trackShipment(string $trackingNumber): array;
    public function cancelShipment(string $shipmentId): bool;
    public function getServiceTypes(): array;
}
Enter fullscreen mode Exit fullscreen mode
Step 2: Create Result DTOs
// app/DTO/ShippingRate.php
namespace App\DTO;

class ShippingRate
{
    public function __construct(
        public readonly string $service,
        public readonly float $price,
        public readonly string $currency = 'USD',
        public readonly string $delivery_time = '',
        public readonly array $metadata = []
    ) {}
}

// app/DTO/ShippingResult.php
namespace App\DTO;

class ShippingResult
{
    public function __construct(
        public readonly bool $success,
        public readonly ?string $trackingNumber = null,
        public readonly ?string $shipmentId = null,
        public readonly ?string $label = null,
        public readonly ?string $error = null,
        public readonly array $metadata = []
    ) {}
}
Enter fullscreen mode Exit fullscreen mode
Step 3: Create the FedEx Adapter
// app/Services/Shipping/FedExAdapter.php
namespace App\Services\Shipping;

use App\Contracts\ShippingInterface;
use App\DTO\ShippingRate;
use App\DTO\ShippingResult;
use App\Models\Order;
use App\Models\Shipment;
use Illuminate\Support\Facades\Log;

class FedExAdapter implements ShippingInterface
{
    private FedExClient $client;

    public function __construct(string $apiKey, string $password, string $accountNumber)
    {
        $this->client = new FedExClient([
            'api_key' => $apiKey,
            'password' => $password,
            'account_number' => $accountNumber,
            'environment' => config('app.env') === 'production' ? 'live' : 'test',
        ]);
    }

    public function getRates(Order $order): array
    {
        try {
            $response = $this->client->getRates([
                'from_address' => $this->getFromAddress(),
                'to_address' => $this->getToAddress($order),
                'package_weight' => $order->total_weight,
                'package_dimensions' => $this->getDimensions($order),
                'currency' => $order->currency,
            ]);

            $rates = [];
            foreach ($response['rates'] as $rate) {
                $rates[] = new ShippingRate(
                    service: $this->mapServiceType($rate['service_type']),
                    price: (float)$rate['total_charge'],
                    currency: $rate['currency'] ?? 'USD',
                    delivery_time: $rate['delivery_time'] ?? '',
                    metadata: ['service_code' => $rate['service_type']]
                );
            }

            return $rates;

        } catch (\Exception $e) {
            Log::error('FedEx rate fetch failed', ['error' => $e->getMessage()]);
            return [];
        }
    }

    public function createShipment(Shipment $shipment): array
    {
        try {
            $response = $this->client->createShipment([
                'from_address' => $this->getFromAddress(),
                'to_address' => $this->getToAddress($shipment->order),
                'package_weight' => $shipment->order->total_weight,
                'package_dimensions' => $this->getDimensions($shipment->order),
                'service_type' => $this->getServiceCode($shipment->service_type),
                'reference' => $shipment->order->id,
                'insurance' => $shipment->order->subtotal > 1000,
            ]);

            $result = new ShippingResult(
                success: true,
                trackingNumber: $response['tracking_number'],
                shipmentId: $response['shipment_id'],
                label: $response['label_base64'] ?? null,
                metadata: [
                    'service_type' => $response['service_type'],
                    'rate' => $response['rate'] ?? 0,
                ]
            );

            Log::info('FedEx shipment created', ['shipment_id' => $response['shipment_id']]);

            return [
                'success' => true,
                'result' => $result,
            ];

        } catch (\Exception $e) {
            Log::error('FedEx shipment creation failed', ['error' => $e->getMessage()]);
            return [
                'success' => false,
                'error' => $e->getMessage(),
            ];
        }
    }

    public function trackShipment(string $trackingNumber): array
    {
        try {
            $response = $this->client->trackShipment($trackingNumber);

            return [
                'success' => true,
                'tracking_number' => $trackingNumber,
                'status' => $response['status'],
                'events' => $response['events'] ?? [],
                'estimated_delivery' => $response['estimated_delivery'] ?? null,
            ];

        } catch (\Exception $e) {
            Log::error('FedEx tracking failed', ['error' => $e->getMessage()]);
            return [
                'success' => false,
                'error' => $e->getMessage(),
            ];
        }
    }

    public function cancelShipment(string $shipmentId): bool
    {
        try {
            $this->client->cancelShipment($shipmentId);
            Log::info('FedEx shipment cancelled', ['shipment_id' => $shipmentId]);
            return true;
        } catch (\Exception $e) {
            Log::error('FedEx cancellation failed', ['error' => $e->getMessage()]);
            return false;
        }
    }

    public function getServiceTypes(): array
    {
        return [
            'ground' => 'FedEx Ground',
            'express' => 'FedEx Express',
            'overnight' => 'FedEx Overnight',
            'international' => 'FedEx International',
        ];
    }

    private function getFromAddress(): array
    {
        return [
            'street' => config('shipping.from_address.street'),
            'city' => config('shipping.from_address.city'),
            'state' => config('shipping.from_address.state'),
            'zip' => config('shipping.from_address.zip'),
            'country' => config('shipping.from_address.country'),
        ];
    }

    private function getToAddress(Order $order): array
    {
        return [
            'street' => $order->shipping_address,
            'city' => $order->shipping_city,
            'state' => $order->shipping_state,
            'zip' => $order->shipping_zip,
            'country' => $order->shipping_country,
        ];
    }

    private function getDimensions(Order $order): array
    {
        // Calculate dimensions from order items
        return [
            'length' => $order->dimension_length ?? 10,
            'width' => $order->dimension_width ?? 10,
            'height' => $order->dimension_height ?? 10,
        ];
    }

    private function mapServiceType(string $fedExService): string
    {
        return match($fedExService) {
            'FEDEX_GROUND' => 'ground',
            'FEDEX_2_DAY' => 'express',
            'FEDEX_OVERNIGHT' => 'overnight',
            'FEDEX_INTERNATIONAL' => 'international',
            default => 'ground',
        };
    }

    private function getServiceCode(string $serviceType): string
    {
        return match($serviceType) {
            'ground' => 'FEDEX_GROUND',
            'express' => 'FEDEX_2_DAY',
            'overnight' => 'FEDEX_OVERNIGHT',
            'international' => 'FEDEX_INTERNATIONAL',
            default => 'FEDEX_GROUND',
        };
    }
}
Enter fullscreen mode Exit fullscreen mode
Step 4: Create the UPS Adapter
// app/Services/Shipping/UPSAdapter.php
namespace App\Services\Shipping;

use App\Contracts\ShippingInterface;
use App\DTO\ShippingRate;
use App\DTO\ShippingResult;
use App\Models\Order;
use App\Models\Shipment;
use Illuminate\Support\Facades\Log;

class UPSAdapter implements ShippingInterface
{
    private UPSClient $client;

    public function __construct(string $accessKey, string $userId, string $password)
    {
        $this->client = new UPSClient([
            'access_key' => $accessKey,
            'user_id' => $userId,
            'password' => $password,
        ]);
    }

    public function getRates(Order $order): array
    {
        try {
            $response = $this->client->getShippingRates([
                'from_zip' => config('shipping.from_address.zip'),
                'to_zip' => $order->shipping_zip,
                'to_country' => $order->shipping_country,
                'weight' => $order->total_weight,
                'dimensions' => $this->getDimensions($order),
            ]);

            $rates = [];
            foreach ($response as $rate) {
                $rates[] = new ShippingRate(
                    service: $this->mapServiceType($rate['service']),
                    price: (float)$rate['cost'],
                    currency: 'USD',
                    delivery_time: $rate['delivery_days'] . ' days',
                    metadata: ['service_code' => $rate['service']]
                );
            }

            return $rates;

        } catch (\Exception $e) {
            Log::error('UPS rate fetch failed', ['error' => $e->getMessage()]);
            return [];
        }
    }

    public function createShipment(Shipment $shipment): array
    {
        try {
            $response = $this->client->processShipment([
                'from_address' => $this->getFromAddress(),
                'to_address' => $this->getToAddress($shipment->order),
                'package' => [
                    'weight' => $shipment->order->total_weight,
                    'dimensions' => $this->getDimensions($shipment->order),
                ],
                'service' => $this->getServiceCode($shipment->service_type),
                'reference' => 'Order-' . $shipment->order->id,
            ]);

            $result = new ShippingResult(
                success: true,
                trackingNumber: $response['tracking_number'],
                shipmentId: $response['shipment_id'],
                label: $response['label_base64'] ?? null,
                metadata: [
                    'service_type' => $response['service_type'],
                    'rate' => $response['rate'] ?? 0,
                ]
            );

            Log::info('UPS shipment created', ['shipment_id' => $response['shipment_id']]);

            return [
                'success' => true,
                'result' => $result,
            ];

        } catch (\Exception $e) {
            Log::error('UPS shipment creation failed', ['error' => $e->getMessage()]);
            return [
                'success' => false,
                'error' => $e->getMessage(),
            ];
        }
    }

    public function trackShipment(string $trackingNumber): array
    {
        try {
            $response = $this->client->track($trackingNumber);

            return [
                'success' => true,
                'tracking_number' => $trackingNumber,
                'status' => $response['status'],
                'events' => $response['activity'] ?? [],
                'estimated_delivery' => $response['estimated_delivery'] ?? null,
            ];

        } catch (\Exception $e) {
            Log::error('UPS tracking failed', ['error' => $e->getMessage()]);
            return [
                'success' => false,
                'error' => $e->getMessage(),
            ];
        }
    }

    public function cancelShipment(string $shipmentId): bool
    {
        try {
            $this->client->cancelShipment($shipmentId);
            Log::info('UPS shipment cancelled', ['shipment_id' => $shipmentId]);
            return true;
        } catch (\Exception $e) {
            Log::error('UPS cancellation failed', ['error' => $e->getMessage()]);
            return false;
        }
    }

    public function getServiceTypes(): array
    {
        return [
            'ground' => 'UPS Ground',
            'express' => 'UPS Express',
            'overnight' => 'UPS Overnight',
            'international' => 'UPS International',
        ];
    }

    // Helper methods (similar to FedEx adapter)
    private function getFromAddress(): array { /* ... */ }
    private function getToAddress(Order $order): array { /* ... */ }
    private function getDimensions(Order $order): array { /* ... */ }
    private function mapServiceType(string $upsService): string { /* ... */ }
    private function getServiceCode(string $serviceType): string { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode
Step 5: The Shipping Service
// app/Services/ShippingService.php
namespace App\Services;

use App\Contracts\ShippingInterface;
use App\Models\Order;
use App\Models\Shipment;

class ShippingService
{
    public function __construct(
        private readonly ShippingInterface $shipping
    ) {}

    public function getRates(Order $order): array
    {
        return $this->shipping->getRates($order);
    }

    public function createShipment(Shipment $shipment): array
    {
        return $this->shipping->createShipment($shipment);
    }

    public function trackShipment(string $trackingNumber): array
    {
        return $this->shipping->trackShipment($trackingNumber);
    }

    public function cancelShipment(string $shipmentId): bool
    {
        return $this->shipping->cancelShipment($shipmentId);
    }

    public function getServiceTypes(): array
    {
        return $this->shipping->getServiceTypes();
    }
}
Enter fullscreen mode Exit fullscreen mode
Step 6: Service Provider with Factory
// app/Providers/ShippingServiceProvider.php
namespace App\Providers;

use App\Contracts\ShippingInterface;
use App\Services\Shipping\FedExAdapter;
use App\Services\Shipping\UPSAdapter;
use App\Services\Shipping\USPSAdapter;
use App\Services\Shipping\DHLAdapter;
use App\Services\ShippingService;
use Illuminate\Support\ServiceProvider;

class ShippingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(ShippingService::class, function ($app) {
            $provider = config('shipping.default_provider', 'fedex');
            $adapter = $this->createAdapter($provider);

            return new ShippingService($adapter);
        });
    }

    private function createAdapter(string $provider): ShippingInterface
    {
        return match($provider) {
            'fedex' => new FedExAdapter(
                config('shipping.fedex.api_key'),
                config('shipping.fedex.password'),
                config('shipping.fedex.account_number')
            ),
            'ups' => new UPSAdapter(
                config('shipping.ups.access_key'),
                config('shipping.ups.user_id'),
                config('shipping.ups.password')
            ),
            'usps' => new USPSAdapter(
                config('shipping.usps.user_id'),
                config('shipping.usps.password')
            ),
            'dhl' => new DHLAdapter(
                config('shipping.dhl.api_key'),
                config('shipping.dhl.api_secret')
            ),
            default => throw new \Exception("Unsupported shipping provider: {$provider}"),
        };
    }
}
Enter fullscreen mode Exit fullscreen mode
Step 7: Controller
// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;

use App\Models\Order;
use App\Services\ShippingService;
use Illuminate\Http\Request;

class OrderController extends Controller
{
    public function __construct(
        private readonly ShippingService $shipping
    ) {}

    public function getRates(Order $order)
    {
        $rates = $this->shipping->getRates($order);

        return response()->json([
            'rates' => array_map(fn($rate) => [
                'service' => $rate->service,
                'price' => $rate->price,
                'currency' => $rate->currency,
                'delivery_time' => $rate->delivery_time,
            ], $rates),
        ]);
    }

    public function createShipment(Request $request, Order $order)
    {
        $shipment = new Shipment();
        $shipment->order_id = $order->id;
        $shipment->service_type = $request->input('service');
        $shipment->save();

        $result = $this->shipping->createShipment($shipment);

        if (!$result['success']) {
            return response()->json(['error' => $result['error']], 400);
        }

        return response()->json([
            'success' => true,
            'tracking_number' => $result['result']->trackingNumber,
            'shipment_id' => $result['result']->shipmentId,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Why This Design Works

  1. Decoupling: Order controller doesn't know about shipping providers.
  2. Consistent Interface: All adapters implement ShippingInterface.
  3. Flexibility: Switch providers by changing configuration.
  4. Testability: Mock ShippingInterface for testing.
  5. Open/Closed: Add a new provider by creating a new adapter.
  6. Encapsulation: Provider-specific complexity is hidden in adapters.

10. SOLID Principles Mapping

O - Open/Closed Principle (OCP)

Add new shipping providers by creating new adapters, without modifying existing code.

// Adding a new provider
class DHLAdapter implements ShippingInterface { /* ... */ }

// No changes to ShippingService or controllers
Enter fullscreen mode Exit fullscreen mode

S - Single Responsibility Principle (SRP)

  • Each adapter handles one provider.
  • The service orchestrates shipping operations.
  • The controller handles HTTP requests.

L - Liskov Substitution Principle (LSP)

All adapters are substitutable for ShippingInterface.

function ship(ShippingInterface $shipping, Order $order)
{
    // Works with any adapter
    return $shipping->createShipment($shipment);
}
Enter fullscreen mode Exit fullscreen mode

D - Dependency Inversion Principle (DIP)

ShippingService depends on ShippingInterface (abstraction), not on concrete adapters.

I - Interface Segregation Principle (ISP)

The interface is focused on shipping operations only.


11. Trade-offs

Benefits

  1. Decoupling: Application code is decoupled from third-party APIs.
  2. Consistency: All adapters implement the same interface.
  3. Testability: Mock the adapter for testing.
  4. Flexibility: Swap providers without changing application code.
  5. Encapsulation: API complexity is hidden.
  6. Open/Closed: Add new providers without modifying existing code.

Costs

  1. More Classes: You need an adapter for each API.
  2. Boilerplate: You need to write the adapter logic.
  3. Indirection: More layers between your code and the API.
  4. Learning Curve: Developers need to understand the pattern.

When Is Complexity Justified?

Use the Adapter Pattern when:

  • You integrate with 3+ third-party APIs.
  • The API might change.
  • You need to be able to swap providers.
  • You want to test your code without real API calls.

Avoid the Adapter Pattern when:

  • You only use one API and it won't change.
  • The API is simple and stable.
  • The overhead of the pattern isn't justified.

12. When NOT To Use It

3 Green Flags (USE ADAPTER)

  1. Multiple APIs: You integrate with 3+ similar services.

  2. API Changes: The third-party API is likely to change.

  3. Testing Needs: You need to mock the API for testing.

3 Red Flags (AVOID ADAPTER)

  1. Single API: You only use one third-party API.

  2. Stable API: The API is stable and won't change.

  3. Simple API: The API is simple and easy to use.


13. Common Mistakes

1. Over-Engineering

// BAD: Adapter for a simple API
class SimpleApiAdapter implements SimpleInterface
{
    public function getData()
    {
        return new SimpleApi()->fetch();
    }
}

// The API is already simple and won't change
Enter fullscreen mode Exit fullscreen mode

2. Not Handling Adapter Exceptions

// BAD: No exception handling
public function charge($amount)
{
    return $this->api->charge($amount);
}

// GOOD: Exception handling
public function charge($amount)
{
    try {
        return $this->api->charge($amount);
    } catch (ApiException $e) {
        throw new AdapterException($e->getMessage());
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Changing the Target Interface

// BAD: Changing the interface
interface PaymentInterface
{
    public function charge($amount, $currency, $paymentMethodId, $metadata); // New parameter!
}
Enter fullscreen mode Exit fullscreen mode

Fix: Keep the interface stable. Use optional parameters or a new interface.

4. Adapter Doing Too Much

// BAD: Adapter handles caching, logging, and API calls
class Adapter
{
    public function getData()
    {
        $this->cache->get();
        $this->logger->log();
        return $this->api->fetch();
    }
}
Enter fullscreen mode Exit fullscreen mode

Fix: Keep the adapter focused on translation. Use decorators for caching and logging.

5. Not Using Dependency Injection

// BAD: Adapter creates its own dependencies
class StripeAdapter
{
    public function __construct()
    {
        $this->stripe = new StripeClient(env('STRIPE_SECRET'));
    }
}

// GOOD: Dependency injection
class StripeAdapter
{
    public function __construct(StripeClient $stripe)
    {
        $this->stripe = $stripe;
    }
}
Enter fullscreen mode Exit fullscreen mode

14. Frequently Asked Interview Questions

Beginner/Intermediate

  1. Q: What is the Adapter Pattern?
    A: A structural pattern that allows objects with incompatible interfaces to work together by acting as a bridge between them.

  2. Q: When would you use the Adapter Pattern?
    A: When integrating with third-party APIs, when you need to swap implementations, or when you want to decouple your application from external services.

  3. Q: How does the Adapter Pattern differ from the Facade Pattern?
    A: Adapter converts one interface to another. Facade provides a simplified interface to a complex system.

  4. Q: How does Laravel use the Adapter Pattern?
    A: In Filesystem adapters, Cache adapters, Queue adapters, Mail adapters, and Session adapters.

  5. Q: What are the benefits of the Adapter Pattern?
    A: Decoupling, consistency, testability, flexibility, and encapsulation.

Senior/Architect

  1. Q: Explain the difference between Class Adapter and Object Adapter.
    A: Class Adapter uses inheritance (extends the adaptee). Object Adapter uses composition (holds a reference to the adaptee). PHP uses Object Adapter.

  2. Q: How do you handle different exceptions from different adapters?
    A: Catch API-specific exceptions and throw a common exception type or return a consistent result array.

  3. Q: How do you test adapters?
    A: Use dependency injection to mock the third-party client. Test the adapter's translation logic.

  4. Q: How do you handle adapter configuration?
    A: Pass configuration to the adapter's constructor or use a factory to create adapters.

  5. Q: What's the relationship between the Adapter Pattern and the Dependency Inversion Principle?
    A: Adapter implements DIP by allowing high-level modules to depend on abstractions (the target interface) rather than concrete third-party APIs.


15. Interactive Practice Challenge

The Requirement

You're building a Notification Delivery System that integrates with multiple notification providers. The current code is tightly coupled to one provider.

The Code (POOR DESIGN)

namespace App\Services;

use Twilio\Rest\Client;
use Illuminate\Support\Facades\Log;

class NotificationService
{
    private Client $twilio;

    public function __construct()
    {
        $this->twilio = new Client(
            config('services.twilio.sid'),
            config('services.twilio.token')
        );
    }

    public function sendSms(string $to, string $message): array
    {
        try {
            $response = $this->twilio->messages->create(
                $to,
                [
                    'from' => config('services.twilio.from'),
                    'body' => $message,
                ]
            );

            Log::info('SMS sent via Twilio', ['to' => $to, 'sid' => $response->sid]);

            return [
                'success' => true,
                'provider' => 'twilio',
                'message_id' => $response->sid,
                'status' => $response->status,
            ];
        } catch (\Exception $e) {
            Log::error('SMS failed', ['error' => $e->getMessage()]);
            return [
                'success' => false,
                'error' => $e->getMessage(),
            ];
        }
    }

    public function sendEmail(string $to, string $subject, string $body): array
    {
        // Using SendGrid (another third-party)
        $sendgrid = new \SendGrid(config('services.sendgrid.api_key'));

        $email = new \SendGrid\Mail\Mail();
        $email->setFrom(config('mail.from.address'), config('mail.from.name'));
        $email->setSubject($subject);
        $email->addTo($to);
        $email->addContent('text/plain', $body);

        try {
            $response = $sendgrid->send($email);

            Log::info('Email sent via SendGrid', ['to' => $to]);

            return [
                'success' => $response->statusCode() === 202,
                'provider' => 'sendgrid',
                'status_code' => $response->statusCode(),
            ];
        } catch (\Exception $e) {
            Log::error('Email failed', ['error' => $e->getMessage()]);
            return [
                'success' => false,
                'error' => $e->getMessage(),
            ];
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Challenges

The code is tightly coupled to Twilio and SendGrid. New requirements are coming:

  1. "We need to support SMS via Vonage."
  2. "We need to support SMS via Amazon SNS."
  3. "We need to support email via Mailgun."
  4. "We need to support email via Amazon SES."
  5. "We need to support push notifications via Firebase."
  6. "We need to support Slack notifications."

Your Task

Refactor this system using the Adapter Pattern. Specifically:

  1. Define target interfaces for SMS and Email.

  2. Create adapters for each provider:

    • TwilioSmsAdapter
    • VonageSmsAdapter
    • AmazonSnsSmsAdapter
    • SendGridEmailAdapter
    • MailgunEmailAdapter
    • AmazonSesEmailAdapter
  3. Create a factory to create the appropriate adapters based on configuration.

  4. Refactor NotificationService to use the adapters.

  5. Add support for push notifications and Slack.

Questions to Consider

  • How do you handle different authentication methods?
  • How do you handle different response formats?
  • How do you handle rate limiting differences?
  • How do you handle retry logic?
  • How do you test the system with different providers?

(We won't provide the solution—refactor this code and master the Adapter Pattern!)


16. Final Mental Model

To keep it simple, memorize these three sentences:

  • One-sentence definition: The Adapter Pattern bridges incompatible interfaces, allowing your application to work with third-party APIs through a consistent interface.

  • One-sentence intuition: Wrap a third-party API in an adapter that speaks your application's language.

  • One-sentence decision rule: If you're integrating with a third-party API that could change or be replaced, use an adapter to decouple your application.


17. Related Concepts

SOLID Principles

  • Open/Closed: Add new adapters without modifying existing code.
  • Dependency Inversion: Depend on the target interface, not concrete adapters.
  • Single Responsibility: Each adapter handles one provider.
  • Liskov Substitution: All adapters are substitutable.
  • Interface Segregation: The target interface is focused.

Design Patterns

  • Facade: Simplifies a complex system.
  • Decorator: Wraps an object to add behavior.
  • Proxy: Controls access to an object.
  • Bridge: Separates abstraction from implementation.
  • Strategy: Encapsulates interchangeable algorithms.

Laravel Internals

  • Filesystem: Uses adapters for local, S3, FTP.
  • Cache: Uses adapters for Redis, Memcached, File.
  • Queue: Uses adapters for Database, Redis, SQS.
  • Mail: Uses adapters for SMTP, Mailgun, Postmark.
  • Session: Uses adapters for Database, File, Redis.
  • Notification: Uses channels (which are similar to adapters).

Enterprise Patterns

  • Adapter: The pattern itself.
  • Wrapper: A common name for adapters.
  • Driver: In Laravel, drivers are often adapters.
  • Connector: Another name for adapters in some contexts.

Final Thoughts

Third-party APIs are the lifeblood of modern applications. They handle payments, send notifications, store files, and provide analytics. But they're also unpredictable. They change. They have different interfaces. They have inconsistent error handling.

The Adapter Pattern is your shield against API chaos. It wraps third-party APIs in a consistent interface, decoupling your application from external services.

Every time you integrate with a third-party API, start with an adapter. Your future self will thank you when the API changes or when you need to switch providers.

Remember: Adapters protect your application from the outside world.


Github: Adapter Pattern Practice Labs

Top comments (0)