← Back to Dashboard

Adapter Pattern

Laravel: Flysystem adapters (Storage::disk()), Cache store adapters

Adapter Pattern

The Real-World Analogy

You are building a payment platform that integrates with multiple billers -- electricity, water, telecoms. Your system has a clean, modern BillerInterface that every biller integration must implement: pay(string $accountId, float $amount): PaymentResult. Your GCash, Maya, and bank integrations all follow this interface beautifully.

Then your product manager walks in: "We need to integrate with the government tax portal. They have a legacy API." You look at their documentation. The legacy system does not accept JSON. It requires XML payloads. It does not use pay() -- it uses submitRemittance(). It does not return a result object -- it returns a raw XML string with embedded status codes. The method signatures, data formats, and response structures are completely incompatible with your interface.

You have two options. You could litter your codebase with special-case handling everywhere the tax biller is used -- checking the biller type, manually converting formats, parsing XML inline. Or you could write an Adapter: a wrapper class that implements your BillerInterface and internally translates every call to the legacy API's language. Your application code never knows it is talking to a legacy system. It sees a BillerInterface, calls pay(), and gets back a PaymentResult.

That is the Adapter pattern: a translator that makes an incompatible interface work with the interface your code expects.

The Anti-Pattern: Scattered Format Conversion

Here is what happens when you try to use a legacy API without an adapter:

class PaymentService
{
    public function processBillerPayment(string $billerType, string $accountId, float $amount): array
    {
        if ($billerType === 'modern') {
            $biller = new ModernBiller();
            return $biller->pay($accountId, $amount);
        }

        if ($billerType === 'legacy-tax') {
            // Inline XML construction -- scattered throughout the codebase
            $xml = "<remittance><tin>{$accountId}</tin><amount>{$amount}</amount></remittance>";
            $legacyApi = new LegacyTaxApi();
            $xmlResponse = $legacyApi->submitRemittance($xml);

            // Inline XML parsing -- duplicated wherever this biller is used
            preg_match('/<status>(.*?)<\/status>/', $xmlResponse, $statusMatch);
            preg_match('/<confirmation>(.*?)<\/confirmation>/', $xmlResponse, $refMatch);

            return [
                'status' => $statusMatch[1] ?? 'UNKNOWN',
                'reference' => $refMatch[1] ?? '',
            ];
        }

        throw new \InvalidArgumentException("Unknown biller type: {$billerType}");
    }
}

Why this is a problem:

  1. Scattered conversion logic. Every place that calls the legacy biller must know about XML construction and parsing. If the legacy API changes its XML schema, you have to find and update every call site.
  2. Conditional branching. The if ($billerType === ...) pattern spreads across the codebase. Each new biller type adds another branch.
  3. Violates Open/Closed Principle. Adding a new biller type means modifying PaymentService, not extending it.
  4. Untestable. You cannot easily mock the legacy API because the conversion logic is interleaved with business logic.

The Fix: Wrap the Legacy API in an Adapter

Define your standard interface, then create an adapter that translates:

interface BillerInterface
{
    public function pay(string $accountId, float $amount): array;
}

class ModernBiller implements BillerInterface
{
    public function pay(string $accountId, float $amount): array
    {
        // Native modern API call
        return ['status' => 'SUCCESS', 'reference' => 'MOD-' . rand(1000, 9999)];
    }
}

class LegacyTaxAdapter implements BillerInterface
{
    public function __construct(
        private LegacyTaxApi $legacyApi
    ) {}

    public function pay(string $accountId, float $amount): array
    {
        // Convert TO the legacy format
        $xml = "<remittance><tin>{$accountId}</tin><amount>{$amount}</amount></remittance>";

        // Call the legacy API in its own language
        $xmlResponse = $this->legacyApi->submitRemittance($xml);

        // Convert FROM the legacy response to our standard format
        preg_match('/<status>(.*?)<\/status>/', $xmlResponse, $statusMatch);
        preg_match('/<confirmation>(.*?)<\/confirmation>/', $xmlResponse, $refMatch);

        return [
            'status' => ($statusMatch[1] ?? '') === 'OK' ? 'SUCCESS' : 'FAILED',
            'reference' => $refMatch[1] ?? '',
        ];
    }
}

Now PaymentService works with any BillerInterface implementation uniformly:

class PaymentService
{
    public function processBillerPayment(BillerInterface $biller, string $accountId, float $amount): array
    {
        return $biller->pay($accountId, $amount);
    }
}

The conversion logic lives in one place (the adapter). The service does not know or care whether it is talking to a modern API or a legacy XML system. Adding a new biller means creating a new class that implements BillerInterface -- no existing code changes.

Object Adapter vs. Class Adapter

There are two forms of the Adapter pattern:

  • Object Adapter (composition): The adapter holds a reference to the adaptee and delegates calls. This is the standard approach in PHP because PHP does not support multiple inheritance. The examples above use object adapters.

  • Class Adapter (inheritance): The adapter extends the adaptee class and implements the target interface simultaneously. This is common in languages with multiple inheritance (C++) but rarely used in PHP.

In practice, always prefer the object adapter. Composition is more flexible than inheritance, and it does not break if the adaptee class is declared final.

When to Use the Adapter Pattern

  • You need to integrate a third-party library or legacy system whose interface does not match yours
  • You are migrating between API versions and need both old and new clients to coexist
  • You want to wrap an external SDK so your application depends on your own interface, not the vendor's
  • You are normalizing multiple data sources that return different formats into a common structure

When NOT to Use It

  • If you control both interfaces, change one of them instead of adding an adapter layer
  • If the "adaptation" is trivial (a single method rename), it may not justify a separate class
  • Do not use an adapter to hide design flaws in your own code. If your interface is wrong, fix the interface

Key takeaway: The Adapter pattern lets incompatible interfaces work together by wrapping one interface to match another. The adapter translates calls and data formats in both directions, keeping the conversion logic isolated in one place instead of scattered across your codebase.

What You Learned

  • The Adapter pattern wraps an incompatible class to make it conform to an interface your code expects
  • Adapters let you integrate legacy or third-party code without modifying it
  • The adapter implements your target interface and delegates to the adaptee's (different) methods internally
  • This pattern is essential when working with external APIs or libraries that don't match your domain contracts

Adapter Pattern in Laravel

Flysystem: The Textbook Example

Laravel's filesystem abstraction is the clearest example of the Adapter pattern in the framework. When you call Storage::disk('s3')->put('file.txt', $content), you are using a unified interface regardless of whether the file ends up on your local disk, Amazon S3, SFTP, or any other storage backend.

How It Works

The Illuminate\Filesystem\FilesystemAdapter class wraps Flysystem's FilesystemOperator. Each storage backend has its own Flysystem adapter:

// These are Flysystem adapters -- each implements the same interface
// but translates calls to a different storage backend

use League\Flysystem\Local\LocalFilesystemAdapter;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use League\Flysystem\Ftp\FtpAdapter;
use League\Flysystem\PhpseclibV3\SftpAdapter;

Your application code never imports or references these adapter classes directly. You interact with Storage::put(), Storage::get(), Storage::delete(), and the framework resolves the correct adapter based on your config/filesystems.php configuration:

// config/filesystems.php
'disks' => [
    'local' => [
        'driver' => 'local',
        'root' => storage_path('app'),
    ],
    's3' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_BUCKET'),
    ],
],

The driver key determines which Flysystem adapter gets instantiated. Each adapter translates the generic filesystem operations (put, get, delete, listContents) into the specific API calls required by that backend. The S3 adapter makes AWS SDK calls. The local adapter uses PHP's filesystem functions. The SFTP adapter uses SSH. Same interface, wildly different implementations.

Why This Is the Adapter Pattern

The core characteristic of the Adapter pattern is present: an intermediary class (AwsS3V3Adapter) implements a standard interface (FilesystemAdapter) and internally translates calls to an incompatible system (the AWS S3 API). Your code depends on the standard interface, not on AWS-specific classes.

Cache Store Adapters

Laravel's cache system follows the same pattern. The Illuminate\Cache\CacheManager resolves a cache "store" based on configuration:

// Each store adapter implements Illuminate\Contracts\Cache\Store
// but talks to a different backend

use Illuminate\Cache\FileStore;      // Adapts file system for caching
use Illuminate\Cache\RedisStore;     // Adapts Redis for caching
use Illuminate\Cache\MemcachedStore; // Adapts Memcached for caching
use Illuminate\Cache\DatabaseStore;  // Adapts a DB table for caching
use Illuminate\Cache\ArrayStore;     // In-memory adapter (testing)

Your application calls Cache::get('key') or Cache::put('key', $value, $ttl) without knowing which backend is in use. The RedisStore adapter translates these calls into Redis GET and SETEX commands. The FileStore adapter reads and writes serialized data to the filesystem. Each adapter handles serialization, TTL management, and error handling in a way appropriate to its backend.

// Switching from file cache to Redis requires zero code changes
// Just update .env:
CACHE_STORE=redis

// Your application code stays exactly the same:
Cache::put('user:123', $userData, 3600);
$user = Cache::get('user:123');

Mail Transport Adapters

The mail system uses adapters to support different transport mechanisms:

// Each transport adapts a different email delivery service
// to Laravel's common mail interface

use Illuminate\Mail\Transport\SesTransport;      // Amazon SES
use Symfony\Component\Mailer\Transport\Smtp\SmtpTransport;  // SMTP
use Illuminate\Mail\Transport\LogTransport;       // Write to log (dev)
use Illuminate\Mail\Transport\ArrayTransport;     // In-memory (testing)

When you call Mail::to($user)->send(new Invoice($order)), the framework dispatches the email through whichever transport is configured. The SesTransport adapter translates the standard mail message into an AWS SES API call. The SmtpTransport adapter speaks SMTP protocol. Same Mail::send() call, completely different network communication underneath.

Writing Your Own Adapter

When integrating a third-party API that does not match your application's interfaces, create an adapter:

// Your application's interface
namespace App\Contracts;

interface PaymentGatewayInterface
{
    public function charge(string $customerId, float $amount, string $currency): PaymentResult;
}

// The third-party SDK has a completely different interface
// vendor/acme/payments-sdk/src/AcmePayments.php
class AcmePayments
{
    public function createTransaction(array $params): AcmeResponse { /* ... */ }
}

// Your adapter bridges the gap
namespace App\Adapters;

use App\Contracts\PaymentGatewayInterface;
use App\Contracts\PaymentResult;
use AcmePayments;

class AcmePaymentAdapter implements PaymentGatewayInterface
{
    public function __construct(
        private AcmePayments $sdk,
    ) {}

    public function charge(string $customerId, float $amount, string $currency): PaymentResult
    {
        $response = $this->sdk->createTransaction([
            'customer_ref' => $customerId,
            'total' => (int) ($amount * 100), // Acme uses cents
            'cur' => strtoupper($currency),
        ]);

        return new PaymentResult(
            success: $response->getStatus() === 'APPROVED',
            reference: $response->getTransactionId(),
            message: $response->getMessage(),
        );
    }
}

Register the adapter in the container:

// AppServiceProvider
$this->app->bind(
    PaymentGatewayInterface::class,
    AcmePaymentAdapter::class,
);

Now every class that depends on PaymentGatewayInterface receives the adapter transparently. If you switch payment providers, you write a new adapter and update the binding. No other code changes.


Key takeaway: Laravel's filesystem, cache, and mail systems are all built on the Adapter pattern. Each subsystem defines a standard interface and provides multiple adapters that translate generic operations into backend-specific calls. When you integrate third-party services, follow the same approach: define your interface, write an adapter, and bind it in the container.

This example is pre-filled and runnable. Click "Run" to see the output. Feel free to modify the code and experiment.

Output

                

            

Complete the code below to pass all test cases. Use the hints if you get stuck.