SEO Metadata
SEO Title Options
- Hexagonal Architecture in PHP: Practical Ports and
- PHP Architecture: Practical 2026 Guide
- Architecture Playbook: PHP Architecture
Meta Description Options
- Learn PHP Architecture with a practical Architecture framework, expert mistakes, implementation steps, examples, FAQ, and schema-ready guidance.
- 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
- What PHP Architecture means
- Why it matters now
- Implementation framework
- Practical comparison
- Expert workflow
- Common mistakes
- Media and link plan
- Original technical deep dive
- FAQ
- Structured data
- Conclusion
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.
- Define the user problem and the production risk.
- Identify the smallest reliable implementation boundary.
- Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
- Add tests for the behavior that would hurt if it regressed.
- Document the trade-off, not only the final code.
- Measure the result with logs, metrics, or user-facing outcomes.
- 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 area | Strong approach | Weak approach | Why it matters |
|---|---|---|---|
| Scope | Solve one clear problem | Mix unrelated concerns | Focus improves testing and search intent |
| Architecture | Put logic in explicit classes or documented boundaries | Hide behavior in templates or incidental callbacks | Future changes stay easier to review |
| Data flow | Pass prepared data into the view or endpoint | Query or compute in presentation code | Reduces regressions and performance surprises |
| Testing | Cover the risky behavior directly | Test only the happy path | Catches production failures earlier |
| Documentation | Explain trade-offs and limits | Repeat generic definitions | Builds E-E-A-T and reader trust |
| Operations | Track logs, metrics, and rollback steps | Ship without measurement | Makes 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]
Media and link plan
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.]
Trustworthy outbound links
- PHP manual - use this as the trust reference for language-level reference.
- Google Search quality guidance - use this as the trust reference for people-first content and E-E-A-T alignment.
Internal linking opportunities
- Internal guide: Master PHP Dependency Injection: A Practical - use this when readers need a related Architecture follow-up.
- Internal guide: Designing a Clean PHP Architecture - use this when readers need a related Architecture follow-up.
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:
| Piece | Meaning in PHP |
|---|---|
| Domain | Business objects and rules with no framework imports |
| Use case | Application operation such as OpenTicket |
| Driving port | Interface used by HTTP, CLI, queue, or tests to drive the use case |
| Driving adapter | Controller, command, worker, cron script, or test harness |
| Driven port | Interface the use case needs from the outside world |
| Driven adapter | PDO 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.
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;
}
}
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;
}
}
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;
}
}
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:
declare(strict_types=1);
namespace App\Support\Application\OpenTicket;
interface OpenTicket
{
public function open(OpenTicketCommand $command): OpenTicketResult;
}
Input DTO:
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:
declare(strict_types=1);
namespace App\Support\Application\OpenTicket;
final readonly class OpenTicketResult
{
public function __construct(
public string $ticketId,
public string $priority,
) {}
}
Driven ports:
declare(strict_types=1);
namespace App\Support\Application\Port;
use App\Support\Domain\Ticket;
interface TicketRepository
{
public function save(Ticket $ticket): void;
}
declare(strict_types=1);
namespace App\Support\Application\Port;
use App\Support\Domain\TicketId;
interface TicketIdGenerator
{
public function next(): TicketId;
}
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.
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.
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.
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:
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:
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:
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:
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.
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:
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:
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:
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.