Back to blog

Architecture

Hexagonal Architecture in PHP: Practical Ports and Adapters Implementation

Builds a complete hexagonal architecture example - domain model, use cases, ports, and adapters for HTTP, CLI, and database.

  • PHP
  • Architecture
  • Hexagonal Architecture
  • Ports and Adapters
  • Testing

Reader map

Key points in Hexagonal Architecture in PHP: Practical Ports and Adapters Implementation

Syntax first, runtime behavior second, migration cleanup last.

Read
12 min
Waypoints
5
Track
Architecture
  1. 01
    Start here

    The same workflow has HTTP, CLI, queue, or batch entry points.

  2. 02
    Waypoint

    Business rules matter more than the framework.

  3. 03
    Waypoint

    External integrations change often.

  4. 04
    Waypoint

    Tests are painful because the core behavior requires a real database or HTTP kernel.

  5. 05
    Migration check

    Long-lived code needs clear boundaries.

SEO Metadata

SEO Title Options

  1. Hexagonal Architecture in PHP: Practical Ports and
  2. PHP Architecture: Practical 2026 Guide
  3. Architecture Playbook: PHP Architecture

Meta Description Options

  1. Learn PHP Architecture with a practical Architecture framework, expert mistakes, implementation steps, examples, FAQ, and schema-ready guidance.
  2. Builds a complete hexagonal architecture example - domain model, use cases, ports, and adapters for HTTP, CLI, and database.

URL Slug

hexagonal-architecture-php-practical-ports-adapters-implementation

Focus Keyword

PHP Architecture

Additional LSI Keywords

  • Architecture
  • PHP
  • Hexagonal Architecture
  • Ports and Adapters
  • Testing
  • Hexagonal Architecture in PHP: Practical Ports and Adapters Implementation
  • production checklist
  • implementation guide
  • best practices
  • architecture decisions
  • testing strategy
  • performance impact

Table of Contents

Article overview

PHP Architecture is the kind of topic that looks simple until it reaches production. Teams usually discover the real cost late: unclear boundaries, weak defaults, hidden maintenance work, and decisions that seemed harmless when the codebase was small.

The problem gets worse when the article, tutorial, or implementation guide only explains the happy path. This guide closes that gap with a practical framework, a comparison table, common mistakes, and a deep technical section you can use while planning real work.

Keep reading for the non-obvious part: the safest implementation is rarely the most impressive-looking one. It is the one your team can debug, test, document, and evolve without turning every future change into archaeology.

Key Takeaways

  • PHP Architecture should be evaluated as a production decision, not only as a syntax or tooling choice.
  • The best implementation keeps responsibilities visible, with clear ownership, tests, documentation, and rollback paths.
  • Search visibility improves when practical depth, structured answers, and expert examples live on the same page.

[IMAGE: A mobile-first technical article layout showing the main concept, decision table, implementation checklist, and FAQ blocks. Alt: PHP Architecture expert guide for Architecture]

What PHP Architecture means

PHP Architecture means applying architecture knowledge to a concrete engineering decision, then turning that decision into reliable code, documentation, and operational behavior. In practice, it combines the topic's core concepts with trade-off analysis, implementation boundaries, testing strategy, and maintenance discipline.

This is the definition worth optimizing for featured snippets because it avoids hype. It tells the reader what the topic does and what a professional implementation must include.

Why it matters now

The technical web is more crowded than it was a few years ago. Thin tutorials can still get indexed, but they rarely earn trust from senior developers, buyers, AI answer systems, or teams that need production guidance.

For architecture topics, the strongest content now has three layers:

  • a clear answer for fast scanning
  • a practical framework for implementation
  • expert context that explains what breaks later

That same structure helps search engines understand the page. It also helps readers decide whether the advice fits their project.

Implementation framework

Use this framework before adopting the approach described in this article.

  1. Define the user problem and the production risk.
  2. Identify the smallest reliable implementation boundary.
  3. Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
  4. Add tests for the behavior that would hurt if it regressed.
  5. Document the trade-off, not only the final code.
  6. Measure the result with logs, metrics, or user-facing outcomes.
  7. Revisit the decision after real usage exposes edge cases.

The sequence is deliberately conservative. It keeps the work grounded in outcomes instead of novelty.

[IMAGE: A seven-step implementation framework with discovery, boundary design, configuration, tests, documentation, measurement, and iteration. Alt: PHP Architecture implementation framework]

Practical comparison

Decision areaStrong approachWeak approachWhy it matters
ScopeSolve one clear problemMix unrelated concernsFocus improves testing and search intent
ArchitecturePut logic in explicit classes or documented boundariesHide behavior in templates or incidental callbacksFuture changes stay easier to review
Data flowPass prepared data into the view or endpointQuery or compute in presentation codeReduces regressions and performance surprises
TestingCover the risky behavior directlyTest only the happy pathCatches production failures earlier
DocumentationExplain trade-offs and limitsRepeat generic definitionsBuilds E-E-A-T and reader trust
OperationsTrack logs, metrics, and rollback stepsShip without measurementMakes the decision reversible

This table is intentionally practical. It gives a reviewer something to check before the implementation becomes expensive to change.

Expert workflow

Expert tip: "Treat PHP Architecture as a system boundary. If the next developer cannot find where the decision lives, how it is tested, and when it should be avoided, the implementation is not finished."

A useful workflow is simple:

  • Start with the smallest working example.
  • Add the constraints that exist in your real project.
  • Remove anything that only demonstrates cleverness.
  • Write down the failure modes.
  • Add links to related decisions so future readers can navigate the topic cluster.

That last point matters for both humans and search systems. A single article can answer a question; a cluster proves authority.

Common mistakes

Mistake 1: Copying a pattern without its context

A pattern that works in a small demo can fail in a real application. The missing context is usually data volume, team experience, deployment process, security requirements, or observability.

Before copying the pattern, ask what assumption made it safe in the original example.

Mistake 2: Putting business logic in the wrong layer

This is the fastest way to make future debugging expensive. In Laravel, PHP, and server-rendered websites, presentation should receive prepared data, not discover rules on its own.

Keep decision logic in models, actions, services, policies, requests, jobs, or documented helpers where it can be tested directly.

Mistake 3: Optimizing for novelty instead of maintainability

Newer tools and language features can be valuable. They can also hide simple behavior behind unfamiliar syntax.

Use the option that makes the next production incident easier to understand.

Mistake 4: Publishing without a measurement plan

If the article describes a performance, SEO, security, or architecture improvement, define how success will be checked. Logs, tests, crawl diagnostics, analytics, and user behavior are all stronger than assumptions.

[IMAGE: A common-mistakes board with context loss, wrong layer, novelty bias, and missing measurement highlighted. Alt: PHP Architecture common mistakes]

Image placeholders

  • [IMAGE: A concept diagram for PHP Architecture with input, decision boundary, implementation, tests, and production feedback. Alt: PHP Architecture concept diagram]
  • [IMAGE: A mobile screenshot-style checklist for Hexagonal Architecture in PHP: Practical Ports and Adapters Implementation. Alt: PHP Architecture mobile checklist]
  • [IMAGE: A comparison table visualization for strong versus weak implementation choices. Alt: PHP Architecture comparison table]

Video placeholder

[VIDEO: Insert a 5-8 minute YouTube walkthrough that demonstrates the main decision, the implementation boundary, the test strategy, and the production caveats for PHP Architecture.]

Internal linking opportunities

Original Technical Deep Dive

Hexagonal architecture is useful when the same business behavior must survive changing frameworks, databases, queues, CLIs, and external services.

It is not useful when it becomes a folder ritual.

The practical rule is simple:

Application core owns the business contract.
Adapters translate between that contract and the outside world.

This guide was reviewed on May 7, 2026 against Alistair Cockburn's ports and adapters article, PHP-FIG PSR-7 and PSR-17, PSR-11, and the PHP PDO manual.

The short version

Hexagonal architecture splits code into:

PieceMeaning in PHP
DomainBusiness objects and rules with no framework imports
Use caseApplication operation such as OpenTicket
Driving portInterface used by HTTP, CLI, queue, or tests to drive the use case
Driving adapterController, command, worker, cron script, or test harness
Driven portInterface the use case needs from the outside world
Driven adapterPDO repository, mailer, payment SDK, file storage, search client

The source dependency points inward:

HTTP Adapter  ->  Application Port  <-  CLI Adapter
                       |
                       v
                    Use Case
                       |
                       v
               Repository Port  <-  PDO Adapter

Control flow can go out to the database. Source code dependency still points inward through an interface.

Use it when:

  • The same workflow has HTTP, CLI, queue, or batch entry points.
  • Business rules matter more than the framework.
  • External integrations change often.
  • Tests are painful because the core behavior requires a real database or HTTP kernel.
  • Long-lived code needs clear boundaries.

Avoid it when:

  • The app is mostly CRUD with little domain behavior.
  • Every "port" has one trivial implementation and no real boundary.
  • Interfaces are created before the use case needs them.
  • The team uses folders to hide unclear responsibilities.

Example feature

We will build one feature:

Open a support ticket.

Inputs:

  • Customer email.
  • Subject.
  • Message.
  • Priority.

Outputs:

  • Ticket ID.
  • Priority accepted by the domain.
  • Persistence to SQL.
  • Optional notification.

Adapters:

  • HTTP action.
  • CLI command.
  • PDO repository.
  • In-memory repository for tests.

Project layout

Use namespaces to make dependencies visible:

src/
  Support/
    Domain/
      Ticket.php
      TicketId.php
      EmailAddress.php
      TicketPriority.php

    Application/
      OpenTicket/
        OpenTicket.php
        OpenTicketCommand.php
        OpenTicketHandler.php
        OpenTicketResult.php
      Port/
        TicketRepository.php
        TicketIdGenerator.php
        TicketNotifier.php

    Adapter/
      In/
        Http/
          OpenTicketAction.php
        Cli/
          OpenTicketCommandRunner.php
      Out/
        Persistence/
          PdoTicketRepository.php
          InMemoryTicketRepository.php
        Id/
          RandomTicketIdGenerator.php
        Notification/
          MailTicketNotifier.php
          NullTicketNotifier.php

Dependency direction:

Domain imports nothing from Application or Adapter.
Application imports Domain and its own ports.
Adapters import Application and Domain.
Bootstrap wires adapters to ports.

Domain model

The domain should be ordinary PHP. No PDO. No PSR request. No framework model base class.

<?php

declare(strict_types=1);

namespace App\Support\Domain;

use InvalidArgumentException;

final readonly class TicketId
{
    public function __construct(private string $value)
    {
        if (! preg_match('/^ticket_[a-zA-Z0-9]{16}$/', $value)) {
            throw new InvalidArgumentException('Invalid ticket ID.');
        }
    }

    public function toString(): string
    {
        return $this->value;
    }
}
<?php

declare(strict_types=1);

namespace App\Support\Domain;

use InvalidArgumentException;

final readonly class EmailAddress
{
    public function __construct(private string $value)
    {
        if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('Invalid customer email.');
        }
    }

    public function toString(): string
    {
        return $this->value;
    }
}
<?php

declare(strict_types=1);

namespace App\Support\Domain;

enum TicketPriority: string
{
    case Low = 'low';
    case Normal = 'normal';
    case High = 'high';

    public static function fromMessage(string $message, string $requested): self
    {
        $priority = self::tryFrom($requested) ?? self::Normal;

        if (str_contains(strtolower($message), 'production down')) {
            return self::High;
        }

        return $priority;
    }
}
<?php

declare(strict_types=1);

namespace App\Support\Domain;

use InvalidArgumentException;

final class Ticket
{
    private function __construct(
        private readonly TicketId $id,
        private readonly EmailAddress $customerEmail,
        private readonly string $subject,
        private readonly string $message,
        private readonly TicketPriority $priority,
        private readonly DateTimeImmutable $openedAt,
    ) {}

    public static function open(
        TicketId $id,
        EmailAddress $customerEmail,
        string $subject,
        string $message,
        TicketPriority $priority,
        DateTimeImmutable $openedAt,
    ): self {
        $subject = trim($subject);
        $message = trim($message);

        if (mb_strlen($subject) < 5) {
            throw new InvalidArgumentException('Ticket subject is too short.');
        }

        if (mb_strlen($message) < 20) {
            throw new InvalidArgumentException('Ticket message is too short.');
        }

        return new self(
            id: $id,
            customerEmail: $customerEmail,
            subject: $subject,
            message: $message,
            priority: $priority,
            openedAt: $openedAt,
        );
    }

    public function id(): TicketId
    {
        return $this->id;
    }

    public function customerEmail(): EmailAddress
    {
        return $this->customerEmail;
    }

    public function subject(): string
    {
        return $this->subject;
    }

    public function message(): string
    {
        return $this->message;
    }

    public function priority(): TicketPriority
    {
        return $this->priority;
    }

    public function openedAt(): DateTimeImmutable
    {
        return $this->openedAt;
    }
}

This domain object enforces business invariants. It does not know who opened the ticket: browser, CLI, queue consumer, or test.

Application ports

Define ports in the application layer because the use case owns what it needs.

Driving port:

<?php

declare(strict_types=1);

namespace App\Support\Application\OpenTicket;

interface OpenTicket
{
    public function open(OpenTicketCommand $command): OpenTicketResult;
}

Input DTO:

<?php

declare(strict_types=1);

namespace App\Support\Application\OpenTicket;

final readonly class OpenTicketCommand
{
    public function __construct(
        public string $customerEmail,
        public string $subject,
        public string $message,
        public string $priority = 'normal',
    ) {}
}

Result DTO:

<?php

declare(strict_types=1);

namespace App\Support\Application\OpenTicket;

final readonly class OpenTicketResult
{
    public function __construct(
        public string $ticketId,
        public string $priority,
    ) {}
}

Driven ports:

<?php

declare(strict_types=1);

namespace App\Support\Application\Port;

use App\Support\Domain\Ticket;

interface TicketRepository
{
    public function save(Ticket $ticket): void;
}
<?php

declare(strict_types=1);

namespace App\Support\Application\Port;

use App\Support\Domain\TicketId;

interface TicketIdGenerator
{
    public function next(): TicketId;
}
<?php

declare(strict_types=1);

namespace App\Support\Application\Port;

use App\Support\Domain\Ticket;

interface TicketNotifier
{
    public function ticketOpened(Ticket $ticket): void;
}

Notice the vocabulary. The use case needs to save tickets, generate ticket IDs, and send a ticket-opened notification. It does not need PDO, SwiftMailer, Laravel Mail, or an HTTP SDK.

[IMAGE: Supporting visual 1 for Hexagonal Architecture in PHP: Practical Ports and Adapters Implementation, showing PHP Architecture decisions, examples, and PHP, Architecture, Hexagonal Architecture. Alt: PHP Architecture hexagonal-architecture-php-practical-ports-adapters-implementation visual 1]

[IMAGE: Supporting visual 1 for Hexagonal Architecture in PHP: Practical Ports and Adapters Implementation, showing PHP Architecture decisions, examples, and PHP, Architecture, Hexagonal Architecture. Alt: PHP Architecture hexagonal-architecture-php-practical-ports-adapters-implementation visual 1]

Use case

The use case coordinates the operation.

<?php

declare(strict_types=1);

namespace App\Support\Application\OpenTicket;

use App\Support\Application\Port\TicketIdGenerator;
use App\Support\Application\Port\TicketNotifier;
use App\Support\Application\Port\TicketRepository;
use App\Support\Domain\EmailAddress;
use App\Support\Domain\Ticket;
use App\Support\Domain\TicketPriority;
use DateTimeImmutable;

final readonly class OpenTicketHandler implements OpenTicket
{
    public function __construct(
        private TicketRepository $tickets,
        private TicketIdGenerator $ids,
        private TicketNotifier $notifier,
    ) {}

    public function open(OpenTicketCommand $command): OpenTicketResult
    {
        $priority = TicketPriority::fromMessage(
            message: $command->message,
            requested: $command->priority,
        );

        $ticket = Ticket::open(
            id: $this->ids->next(),
            customerEmail: new EmailAddress($command->customerEmail),
            subject: $command->subject,
            message: $command->message,
            priority: $priority,
            openedAt: new DateTimeImmutable(),
        );

        $this->tickets->save($ticket);
        $this->notifier->ticketOpened($ticket);

        return new OpenTicketResult(
            ticketId: $ticket->id()->toString(),
            priority: $ticket->priority()->value,
        );
    }
}

This is the center of the feature. It has no HTTP, CLI, PDO, mail transport, framework request, framework response, or global container lookup.

HTTP driving adapter

The HTTP adapter translates a request into an application command and translates the result into an HTTP response.

This example uses PSR-7 request/response interfaces and PSR-17 factories. In Laravel or Symfony, the same adapter shape applies, but the request and response classes would be framework-specific.

<?php

declare(strict_types=1);

namespace App\Support\Adapter\In\Http;

use App\Support\Application\OpenTicket\OpenTicket;
use App\Support\Application\OpenTicket\OpenTicketCommand;
use InvalidArgumentException;
use JsonException;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;

final readonly class OpenTicketAction
{
    public function __construct(
        private OpenTicket $openTicket,
        private ResponseFactoryInterface $responses,
        private StreamFactoryInterface $streams,
    ) {}

    public function __invoke(ServerRequestInterface $request): ResponseInterface
    {
        $body = $request->getParsedBody();

        if (! is_array($body)) {
            return $this->json(['error' => 'Invalid request body.'], 400);
        }

        try {
            $result = $this->openTicket->open(new OpenTicketCommand(
                customerEmail: (string) ($body['email'] ?? ''),
                subject: (string) ($body['subject'] ?? ''),
                message: (string) ($body['message'] ?? ''),
                priority: (string) ($body['priority'] ?? 'normal'),
            ));
        } catch (InvalidArgumentException $exception) {
            return $this->json(['error' => $exception->getMessage()], 422);
        }

        return $this->json([
            'data' => [
                'ticket_id' => $result->ticketId,
                'priority' => $result->priority,
            ],
        ], 201);
    }

    /**
     * @param array<string, mixed> $payload
     *
     * @throws JsonException
     */
    private function json(array $payload, int $status): ResponseInterface
    {
        $response = $this->responses
            ->createResponse($status)
            ->withHeader('Content-Type', 'application/json');

        return $response->withBody($this->streams->createStream(
            json_encode($payload, JSON_THROW_ON_ERROR),
        ));
    }
}

The controller is not the use case. It is an adapter.

Do not pass ServerRequestInterface into OpenTicketHandler. That would make HTTP part of the application core.

CLI driving adapter

The CLI adapter drives the same port.

<?php

declare(strict_types=1);

namespace App\Support\Adapter\In\Cli;

use App\Support\Application\OpenTicket\OpenTicket;
use App\Support\Application\OpenTicket\OpenTicketCommand;
use InvalidArgumentException;

final readonly class OpenTicketCommandRunner
{
    public function __construct(private OpenTicket $openTicket) {}

    /**
     * @param array<int, string> $argv
     */
    public function run(array $argv): int
    {
        $options = getopt('', [
            'email:',
            'subject:',
            'message:',
            'priority::',
        ]);

        if (! is_array($options)) {
            fwrite(STDERR, "Unable to parse arguments.\n");

            return 1;
        }

        try {
            $result = $this->openTicket->open(new OpenTicketCommand(
                customerEmail: (string) ($options['email'] ?? ''),
                subject: (string) ($options['subject'] ?? ''),
                message: (string) ($options['message'] ?? ''),
                priority: (string) ($options['priority'] ?? 'normal'),
            ));
        } catch (InvalidArgumentException $exception) {
            fwrite(STDERR, $exception->getMessage()."\n");

            return 1;
        }

        fwrite(STDOUT, "Opened {$result->ticketId} with {$result->priority} priority.\n");

        return 0;
    }
}

Run shape:

php bin/open-ticket \
  --email=ada@example.com \
  --subject="Production error" \
  --message="The checkout returns a 500 error for paid orders." \
  --priority=high

The CLI adapter has no duplicate business rule. It only parses command-line input and calls the port.

Database driven adapter

The database adapter implements the repository port.

Schema:

CREATE TABLE support_tickets (
    id VARCHAR(32) PRIMARY KEY,
    customer_email VARCHAR(255) NOT NULL,
    subject VARCHAR(180) NOT NULL,
    message TEXT NOT NULL,
    priority VARCHAR(20) NOT NULL,
    opened_at TIMESTAMP NOT NULL
);

CREATE INDEX support_tickets_priority_opened_index
    ON support_tickets (priority, opened_at);

PDO implementation:

<?php

declare(strict_types=1);

namespace App\Support\Adapter\Out\Persistence;

use App\Support\Application\Port\TicketRepository;
use App\Support\Domain\Ticket;
use PDO;

final readonly class PdoTicketRepository implements TicketRepository
{
    public function __construct(private PDO $pdo) {}

    public function save(Ticket $ticket): void
    {
        $statement = $this->pdo->prepare(
            'INSERT INTO support_tickets (
                id,
                customer_email,
                subject,
                message,
                priority,
                opened_at
            ) VALUES (
                :id,
                :customer_email,
                :subject,
                :message,
                :priority,
                :opened_at
            )',
        );

        $statement->execute([
            'id' => $ticket->id()->toString(),
            'customer_email' => $ticket->customerEmail()->toString(),
            'subject' => $ticket->subject(),
            'message' => $ticket->message(),
            'priority' => $ticket->priority()->value,
            'opened_at' => $ticket->openedAt()->format('Y-m-d H:i:s'),
        ]);
    }
}

PDO belongs here because this class is an adapter. If the database changes, this class changes. The use case does not.

For multi-step persistence, wrap the adapter operation in a transaction or introduce an application-level transaction port:

interface TransactionRunner
{
    public function run(callable $operation): mixed;
}

Do not scatter transaction control across HTTP controllers.

ID and notification adapters

ID adapter:

<?php

declare(strict_types=1);

namespace App\Support\Adapter\Out\Id;

use App\Support\Application\Port\TicketIdGenerator;
use App\Support\Domain\TicketId;
use Random\RandomException;

final class RandomTicketIdGenerator implements TicketIdGenerator
{
    /**
     * @throws RandomException
     */
    public function next(): TicketId
    {
        return new TicketId('ticket_'.bin2hex(random_bytes(8)));
    }
}

Notification adapter:

<?php

declare(strict_types=1);

namespace App\Support\Adapter\Out\Notification;

use App\Support\Application\Port\TicketNotifier;
use App\Support\Domain\Ticket;

final readonly class MailTicketNotifier implements TicketNotifier
{
    public function __construct(private string $supportEmail) {}

    public function ticketOpened(Ticket $ticket): void
    {
        mail(
            $this->supportEmail,
            'New support ticket '.$ticket->id()->toString(),
            $ticket->subject()."\n\n".$ticket->message(),
        );
    }
}

For local development or tests:

<?php

declare(strict_types=1);

namespace App\Support\Adapter\Out\Notification;

use App\Support\Application\Port\TicketNotifier;
use App\Support\Domain\Ticket;

final class NullTicketNotifier implements TicketNotifier
{
    public function ticketOpened(Ticket $ticket): void
    {
    }
}

The application core does not care whether notifications use mail(), Symfony Mailer, Laravel Mail, SES, Postmark, or a fake.

In-memory adapter for tests

Testing is the payoff.

<?php

declare(strict_types=1);

namespace App\Support\Adapter\Out\Persistence;

use App\Support\Application\Port\TicketRepository;
use App\Support\Domain\Ticket;

final class InMemoryTicketRepository implements TicketRepository
{
    /** @var array<string, Ticket> */
    public array $tickets = [];

    public function save(Ticket $ticket): void
    {
        $this->tickets[$ticket->id()->toString()] = $ticket;
    }
}

Deterministic ID generator for tests:

<?php

declare(strict_types=1);

use App\Support\Application\Port\TicketIdGenerator;
use App\Support\Domain\TicketId;

final class FixedTicketIdGenerator implements TicketIdGenerator
{
    public function next(): TicketId
    {
        return new TicketId('ticket_ABCDEF1234567890');
    }
}

Use case test:

<?php

declare(strict_types=1);

use App\Support\Adapter\Out\Notification\NullTicketNotifier;
use App\Support\Adapter\Out\Persistence\InMemoryTicketRepository;
use App\Support\Application\OpenTicket\OpenTicketCommand;
use App\Support\Application\OpenTicket\OpenTicketHandler;

it('opens a support ticket without a database', function (): void {
    $tickets = new InMemoryTicketRepository();

    $handler = new OpenTicketHandler(
        tickets: $tickets,
        ids: new FixedTicketIdGenerator(),
        notifier: new NullTicketNotifier(),
    );

    $result = $handler->open(new OpenTicketCommand(
        customerEmail: 'ada@example.com',
        subject: 'Checkout error',
        message: 'The checkout returns a 500 error after payment.',
        priority: 'normal',
    ));

    expect($result->ticketId)->toBe('ticket_ABCDEF1234567890');
    expect($tickets->tickets)->toHaveCount(1);
});

Domain test:

it('raises priority for production-down messages', function (): void {
    $priority = TicketPriority::fromMessage(
        message: 'Production down after deployment.',
        requested: 'normal',
    );

    expect($priority)->toBe(TicketPriority::High);
});

These tests do not boot a framework, create a database, run migrations, fake HTTP, or send mail.

Adapter tests are separate

Still test adapters. Just do not mix adapter confidence with use-case confidence.

PDO repository test:

it('persists support tickets with PDO', function (): void {
    $pdo = new PDO('sqlite::memory:');
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $pdo->exec(
        'CREATE TABLE support_tickets (
            id VARCHAR(32) PRIMARY KEY,
            customer_email VARCHAR(255) NOT NULL,
            subject VARCHAR(180) NOT NULL,
            message TEXT NOT NULL,
            priority VARCHAR(20) NOT NULL,
            opened_at TIMESTAMP NOT NULL
        )',
    );

    $repository = new PdoTicketRepository($pdo);

    $ticket = Ticket::open(
        id: new TicketId('ticket_ABCDEF1234567890'),
        customerEmail: new EmailAddress('ada@example.com'),
        subject: 'Checkout error',
        message: 'The checkout returns a 500 error after payment.',
        priority: TicketPriority::High,
        openedAt: new DateTimeImmutable('2026-04-22 12:00:00'),
    );

    $repository->save($ticket);

    $count = $pdo
        ->query('SELECT COUNT(*) FROM support_tickets')
        ->fetchColumn();

    expect((int) $count)->toBe(1);
});

The test is slower than the use-case test, but it verifies the SQL adapter. That is a different risk.

Wiring

The container or bootstrap file belongs at the edge.

Plain PHP wiring:

<?php

declare(strict_types=1);

use App\Support\Adapter\Out\Id\RandomTicketIdGenerator;
use App\Support\Adapter\Out\Notification\MailTicketNotifier;
use App\Support\Adapter\Out\Persistence\PdoTicketRepository;
use App\Support\Application\OpenTicket\OpenTicket;
use App\Support\Application\OpenTicket\OpenTicketHandler;

$pdo = new PDO($_ENV['DATABASE_DSN'], $_ENV['DATABASE_USER'], $_ENV['DATABASE_PASSWORD']);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$openTicket = new OpenTicketHandler(
    tickets: new PdoTicketRepository($pdo),
    ids: new RandomTicketIdGenerator(),
    notifier: new MailTicketNotifier($_ENV['SUPPORT_EMAIL']),
);

return [
    OpenTicket::class => $openTicket,
];

With a DI container, register the same bindings there. PSR-11 can standardize access to container entries, but the use case should not pull dependencies from the container:

// Bad inside a use case:
$repository = $container->get(TicketRepository::class);

[IMAGE: Supporting visual 2 for Hexagonal Architecture in PHP: Practical Ports and Adapters Implementation, showing PHP Architecture decisions, examples, and PHP, Architecture, Hexagonal Architecture. Alt: PHP Architecture hexagonal-architecture-php-practical-ports-adapters-implementation visual 2]

Constructor injection keeps dependencies explicit:

public function __construct(
    private TicketRepository $tickets,
    private TicketIdGenerator $ids,
    private TicketNotifier $notifier,
) {}

The container is an assembly tool, not a business dependency.

Common mistakes

Port named after technology

Weak:

interface PdoTicketStorage
{
    public function insert(array $row): void;
}

[IMAGE: Supporting visual 2 for Hexagonal Architecture in PHP: Practical Ports and Adapters Implementation, showing PHP Architecture decisions, examples, and PHP, Architecture, Hexagonal Architecture. Alt: PHP Architecture hexagonal-architecture-php-practical-ports-adapters-implementation visual 2]

Better:

interface TicketRepository
{
    public function save(Ticket $ticket): void;
}

The port should describe what the application needs, not the adapter technology.

Controller doing use-case work

Weak:

$priority = str_contains($message, 'production down') ? 'high' : 'normal';
$pdo->prepare('INSERT INTO support_tickets ...')->execute(...);
mail($supportEmail, 'New ticket', $message);

Better:

$result = $this->openTicket->open(new OpenTicketCommand(...));

The controller parses HTTP and serializes HTTP. The use case owns behavior.

Domain importing infrastructure

Weak:

final class Ticket extends Model
{
}

That may be fine for CRUD. It is not a protected domain model.

Better:

final class Ticket
{
    // Business state and behavior only.
}

Persistence mapping belongs in the adapter.

Too many interfaces

Do not create a port for every class.

Create a port when:

  • The use case needs an external capability.
  • The implementation is likely to vary.
  • Testing the core needs a fake adapter.
  • The boundary is meaningful in business language.

Do not create ports for private helper classes, immutable value objects, or one-line pure functions.

Production checklist

Before calling the architecture hexagonal:

  • Domain code has no framework, database, HTTP, or SDK imports.
  • Use cases depend on application-defined ports.
  • Ports use business vocabulary.
  • HTTP, CLI, queue, and cron code are driving adapters.
  • Database, mail, storage, search, and payment code are driven adapters.
  • Bootstrap or the DI container wires concrete adapters.
  • Use-case tests run without infrastructure.
  • Adapter tests cover real SQL, real serialization, and real SDK mapping.
  • Transactions are owned by a deliberate boundary.
  • Framework request and response objects do not cross inward.

Hexagonal architecture is not about drawing a hexagon. It is about making the business core runnable without the outside world, then attaching the outside world through adapters.

FAQ

What is PHP Architecture?

PHP Architecture is a practical architecture topic that should be evaluated through implementation scope, production risk, testing, documentation, and long-term maintainability.

When should a team use PHP Architecture?

Use PHP Architecture when it solves a real project constraint, improves clarity, or reduces operational risk. Avoid it when it only adds novelty or hides behavior from future maintainers.

What is the biggest risk with PHP Architecture?

The biggest risk is copying a pattern without its context. Production systems need clear boundaries, rollback options, tests, and observability before a technique becomes dependable.

How do you test PHP Architecture?

Test the smallest unit that owns the behavior, then add integration coverage for the path users or systems actually rely on. Include failure cases, configuration differences, and regression checks.

How does PHP Architecture affect SEO and AI search visibility?

It improves visibility when the article gives a direct answer, expert context, structured headings, internal links, trustworthy references, and FAQ content that matches the visible page.

Conclusion

PHP Architecture is worth doing when the implementation improves clarity, reliability, or delivery speed. It is not worth doing when it hides ownership, increases operational risk, or makes the system harder to explain.

Use the framework above as a review checklist. Then connect this topic to the rest of the project documentation so readers can move from concept to implementation without losing context.

Top