DEV Community

saad mouizina
saad mouizina

Posted on • Originally published at Medium on

[PHP] Understanding Ports & Adapters

Ports and Adapters is a well-known and widely used design pattern — often without even realizing it. Foundational to maintainable and testable applications, this pattern separates the what (your core logic) from the how (external systems or tools). It’s commonly embedded in frameworks like Symfony and Laravel through features like dependency injection, contracts, and service containers.

In this article, we’ll focus specifically on the Ports and Adapters concept, without diving into full Hexagonal architecture.

What Are Ports & Adapters?

Ports are PHP interfaces that define the actions your application expects (inputs) or provides (outputs). Think of them as your contracts.

Adapters are concrete implementations of those interfaces. They allow communication with external systems: databases, APIs, file storage, queues, etc.

By depending on interfaces rather than implementations, your core application becomes easier to test, change, and extend. For instance:

  • You can mock external services in tests.
  • You can change how data is stored without touching business logic.
  • You can add new integrations just by writing a new adapter.

This is how frameworks like Laravel let you swap out mail drivers, queue backends, or filesystem disks with ease.

The Use Case: Configurable Data Importer

Let’s walk through a typical example to see Ports & Adapters in action: a system that imports user data from multiple sources (CSV, HTTP API) and stores them in multiple formats (MySQL, JSONL files).

We want to:

  • Add new sources/targets without modifying the core logic
  • Drive the behavior through config
  • Keep the application decoupled from external systems

Step 1: Define Contracts (Ports)

Let’s define our contracts :

interface ImportSourceInterface
{
    /** @return Collection<ImportDto> */
    public function fetch(): Collection;
}

interface ImportTargetInterface
{
    /** @param Collection<ImportDto> $records */
    public function store(Collection $records): void;
}
Enter fullscreen mode Exit fullscreen mode

The ImportDto is a simple data carrier:

class ImportDto
{
    public function __construct(
        public readonly string $email,
        public readonly string $name,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Create Adapters

namespace App\Adapters\Sources;

use App\Contracts\ImportSourceInterface;
use App\DTO\ImportDto;
use Illuminate\Support\Collection;

class CsvFileSource implements ImportSourceInterface
{
    public function __construct(private string $path) {}

    /**
     * @return Collection<ImportDto>
     */
    public function fetch(): Collection
    {
        $rows = collect();

        if (! file_exists($this->path)) {
            throw new \RuntimeException("CSV file not found at {$this->path}");
        }

        $handle = fopen($this->path, 'r');
        $headers = fgetcsv($handle);

        while ($line = fgetcsv($handle)) {
            $row = array_combine($headers, $line);
            $rows->push(new ImportDto(
                email: $row['email'] ?? '',
                name: $row['name'] ?? '',
            ));
        }

        fclose($handle);

        return $rows;
    }
}

<?php

namespace App\Adapters\Sources;

use App\Contracts\ImportSourceInterface;
use App\DTO\ImportDto;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;

class HttpClientSource implements ImportSourceInterface
{
    public function __construct(private string $url, private ?string $token = null) {}

    /**
     * @return Collection<ImportDto>
     */
    public function fetch(): Collection
    {
        $response = Http::withToken($this->token)->get($this->url);

        if (! $response->successful()) {
            throw new \RuntimeException('Unable to fetch data from HTTP source.');
        }

        return collect($response->json())
            ->map(fn (array $item) => new ImportDto(
                email: $item['email'] ?? '',
                name: $item['name'] ?? '',
            ));
    }
}

<?php

namespace App\Adapters\Targets;

use App\Contracts\ImportTargetInterface;
use App\DTO\ImportDto;
use App\Models\ImportedUser;
use Illuminate\Support\Collection;

class EloquentStorage implements ImportTargetInterface
{
    public function store(Collection $records): void
    {
        $users = $records->map(function (ImportDto $record) {
            return [
                'email' => $record->email,
                'name' => $record->name,
            ];
        });

        ImportedUser::insert($users->toArray());
    }
}

<?php

namespace App\Adapters\Targets;

use App\Contracts\ImportTargetInterface;
use Illuminate\Support\Collection;

class FileStorage implements ImportTargetInterface
{
    public function __construct(
        private string $path,
    ) {}

    public function store(Collection $records): void
    {
        file_put_contents(
            filename: $this->path,
            data: json_encode($records)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Each adapter only cares about its specific task. They don’t know where they’re called from or why. That’s the job of our orchestration layer.

Step 3: Config-Driven Behavior:

We use a single port-adapters.php config file to list available sources, targets, and define the jobs.

<?php

return [
    // 👇 List of available sources
    'sources' => [
        'http_users' => [
            'class' => \App\Adapters\Sources\HttpClientSource::class,
            'arguments' => [
                'url' => env('USERS_API_URL'),
                'token' => env('USERS_API_TOKEN'),
            ],
        ],
        'local_csv' => [
            'class' => \App\Adapters\Sources\CsvFileSource::class,
            'arguments' => [
                'path' => storage_path('imports/users.csv'),
            ],
        ],
    ],

    // 👇 List of available targets
    'targets' => [
        'mysql' => [
            'class' => \App\Adapters\Targets\EloquentStorage::class,
            'arguments' => [],
        ],
        'file' => [
            'class' => \App\Adapters\Targets\FileStorage::class,
            'arguments' => [
                'path' => storage_path('imports/output/users.jsonl'),
            ],
        ],
    ],

    // 👇 Import job definitions (source ↔ target)
    'jobs' => [
        'users_from_http' => [
            'source' => 'http_users',
            'target' => 'file',
        ],
        'users_from_csv' => [
            'source' => 'local_csv',
            'target' => 'mysql',
        ],
    ],

];
Enter fullscreen mode Exit fullscreen mode

This allows defining import jobs like users_from_http or users_from_csv dynamically.

Step 4: Job Runner Service

<?php

namespace App\Services;

use App\Contracts\ImportSourceInterface;
use App\Contracts\ImportTargetInterface;

class ImportJobRunner
{
    public function __construct(private string $jobName) {}

    public function run(): void
    {
        $job = config("import_jobs.{$this->jobName}");
        $sourceConfig = config("import_sources.{$job['source']}");
        $targetConfig = config("import_targets.{$job['target']}");

        $source = $this->makeAdapter($sourceConfig, ImportSourceInterface::class);
        $target = $this->makeAdapter($targetConfig, ImportTargetInterface::class);

        $target->store(
            $source->fetch()
        );

    }

    protected function makeAdapter(array $config, string $interface): object
    {
        $instance = app()->makeWith($config['class'], $config['arguments'] ?? []);

        if (! $instance instanceof $interface) {
            throw new \RuntimeException('Adapter does not implement required interface.');
        }

        return $instance;
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the heart of the system: given a job name, it dynamically resolves the correct adapters and runs the import.

Functional Tests

I wrote tests to cover each combination:

  • CSV -> MySQL
  • CSV -> File
  • HTTP -> MySQL
  • HTTP -> File
<?php

namespace Tests\Feature;

use App\Adapters\Sources\CsvFileSource;
use App\Adapters\Sources\HttpClientSource;
use App\Adapters\Targets\EloquentStorage;
use App\Adapters\Targets\FileStorage;
use App\Services\ImportJobRunner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class ImportJobRunnerTest extends TestCase
{
    use RefreshDatabase;

    #[Test]
    public function it_imports_from_csv_to_mysql(): void
    {
        config()->set('import_jobs.users_from_csv', [
            'source' => 'csv_users',
            'target' => 'mysql',
        ]);

        config()->set('import_sources.csv_users', [
            'class' => CsvFileSource::class,
            'arguments' => [
                'path' => storage_path('imports/users.csv'),
            ],
        ]);

        config()->set('import_targets.mysql', [
            'class' => EloquentStorage::class,
            'arguments' => [],
        ]);

        (new ImportJobRunner('users_from_csv'))->run();

        $this->assertDatabaseCount('imported_users', 4);
        $this->assertDatabaseHas('imported_users', ['email' => 'alice@example.com']);
        $this->assertDatabaseHas('imported_users', ['email' => 'bob@example.com']);
        $this->assertDatabaseHas('imported_users', ['email' => 'carla@example.com']);
        $this->assertDatabaseHas('imported_users', ['email' => 'mohamed@example.com']);

    }

    #[Test]
    public function it_imports_from_csv_to_file(): void
    {
        config()->set('import_jobs.csv_to_file', [
            'source' => 'csv_users',
            'target' => 'file',
        ]);

        config()->set('import_sources.csv_users', [
            'class' => CsvFileSource::class,
            'arguments' => [
                'path' => storage_path('imports/users.csv'),
            ],
        ]);

        config()->set('import_targets.file', [
            'class' => FileStorage::class,
            'arguments' => [
                'path' => storage_path('imports/users.jsonl'),
            ],
        ]);

        (new ImportJobRunner('csv_to_file'))->run();

        $this->assertFileExists(storage_path('imports/users.jsonl'));
        $content = File::get(storage_path('imports/users.jsonl'));

        $this->assertStringContainsString('mohamed@example.com', $content);
        $this->assertCount(4, json_decode($content, true));
    }

    #[Test]
    public function it_imports_from_http_to_mysql(): void
    {
        Http::fake([
            '*' => Http::response([
                ['email' => 'foo@example.com', 'name' => 'Foo'],
                ['email' => 'bar@example.com', 'name' => 'Bar'],
            ]),
        ]);

        config()->set('import_jobs.http_to_mysql', [
            'source' => 'http_users',
            'target' => 'mysql',
        ]);

        config()->set('import_sources.http_users', [
            'class' => HttpClientSource::class,
            'arguments' => [
                'url' => 'https://dummy.test/api/users',
                'token' => 'dummy_token',
            ],
        ]);

        config()->set('import_targets.mysql', [
            'class' => EloquentStorage::class,
            'arguments' => [],
        ]);

        (new ImportJobRunner('http_to_mysql'))->run();

        $this->assertDatabaseCount('imported_users', 2);
        $this->assertDatabaseHas('imported_users', ['email' => 'foo@example.com']);
        $this->assertDatabaseHas('imported_users', ['email' => 'bar@example.com']);
    }

    #[Test]
    public function it_imports_from_http_to_file(): void
    {
        Http::fake([
            '*' => Http::response([
                ['email' => 'zara@example.com', 'name' => 'Zara'],
            ]),
        ]);

        config()->set('import_jobs.http_to_file', [
            'source' => 'http_users',
            'target' => 'file',
        ]);

        config()->set('import_sources.http_users', [
            'class' => HttpClientSource::class,
            'arguments' => [
                'url' => 'https://dummy.test/api/users',
                'token' => 'dummy_token',
            ],
        ]);

        config()->set('import_targets.file', [
            'class' => FileStorage::class,
            'arguments' => [
                'path' => storage_path('imports/users.jsonl'),
            ],
        ]);

        (new ImportJobRunner('http_to_file'))->run();

        $this->assertFileExists(storage_path('imports/users.jsonl'));
        $this->assertStringContainsString('zara@example.com', File::get(storage_path('imports/users.jsonl')));
    }

    #[Test]
    public function it_throws_if_adapter_does_not_implement_interface(): void
    {
        config()->set('import_jobs.invalid_adapter', [
            'source' => 'invalid_source',
            'target' => 'mysql',
        ]);

        config()->set('import_sources.invalid_source', [
            'class' => \stdClass::class, // Ne respecte pas ImportSourceInterface
            'arguments' => [],
        ]);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Adapter does not implement required interface');

        (new ImportJobRunner('invalid_adapter'))->run();
    }
}
Enter fullscreen mode Exit fullscreen mode

These tests:

  • Ensure the correct integration between source & target
  • Test that the contracts are respected
  • Prove that the system can adapt dynamically to config changes

Conclusion

Ports & Adapters isn’t just a theory — it’s a powerful way to write maintainable code.

  • Your business logic doesn’t know how data is fetched or stored.
  • Your adapters don’t know why they’re used — just how.
  • Your application becomes easier to test and evolve.

Github repository : https://github.com/mouize/demo-ports-adapter

Top comments (0)