DEV Community

Cover image for The src/ Layout for Hexagonal PHP: Layers and Import Rules
Gabriel Anhaia
Gabriel Anhaia

Posted on

The src/ Layout for Hexagonal PHP: Layers and Import Rules


You read the hexagonal post, you liked the hexagon diagram, and then you opened your editor and made three folders: Domain, Application, Infrastructure. Good start. Two weeks later a use Doctrine\ORM\EntityManagerInterface sits at the top of a domain entity, and nobody remembers approving it. The folders were a suggestion. Nothing stopped the import.

The layout only matters if the boundaries are enforced. This post gives you the concrete src/ tree, the namespace-to-layer map, the exact directions an import is allowed to point, and the CI check that fails the build when someone points one the wrong way. Target is PHP 8.4+ with Composer PSR-4.

The tree

Three top-level namespaces under src/, mapped one-to-one to three directories.

your-app/
  composer.json
  src/
    Domain/                # pure PHP, no vendor imports
      Order/
        Order.php
        OrderId.php
        OrderStatus.php
      Customer/
      Shared/
        Money.php
    Application/           # use cases + the ports
      Order/
        PlaceOrder.php
        PlaceOrderInput.php
      Port/
        OrderRepository.php
        PaymentGateway.php
        Clock.php
    Infrastructure/       # adapters, framework, wiring
      Persistence/Doctrine/
      Http/Controller/
      Payment/Stripe/
      Clock/SystemClock.php
      Bootstrap/Container.php
  tests/
Enter fullscreen mode Exit fullscreen mode

Each ring is a layer. Domain is the center. Infrastructure is the outer edge that touches the world. Application sits between, holding the use cases and the interfaces (ports) the use cases depend on.

The namespace map

One PSR-4 root, three sub-namespaces that shadow the directories exactly.

{
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

So src/Domain/Order/Order.php is App\Domain\Order\Order, and the first segment after App\ tells you the layer at a glance. No App\Models, no App\Services, no App\Http scattered across the root. The namespace is the architecture. When you read an import, the second segment answers "which ring does this live in" before you read the class name.

Keep the framework's own namespace out of src/. Symfony's App\Controller convention and Laravel's App\Http both flatten everything into one bag. You want the opposite: the layer is load-bearing, so it goes first.

The import rules

Here is the whole architecture in one table. Rows are the file doing the importing; columns are what it is allowed to import.

From \ imports Domain Application Infrastructure Vendor
Domain yes no no no
Application yes yes no no*
Infrastructure yes yes yes yes

The rule reads in one direction: imports point inward, never outward. Domain imports only itself. Application imports Domain and itself. Infrastructure imports everything, because it is the layer whose whole job is to translate between the outside world and the inside.

The no* on Application-to-vendor has one carve-out worth naming: PSR interfaces (Psr\Log\LoggerInterface, Psr\Clock\ClockInterface) and pure library value types are sometimes allowed there, because they are contracts, not implementations. Decide it once for the team and encode the decision in the CI config below, so it is not re-argued in every review.

Two consequences fall out of the table:

  • A domain entity that needs now() cannot call new DateTimeImmutable() and stay honest — that is a decision from the outside world. It takes the time as an argument, or the use case reads a Clock port. The Clock interface lives in Application\Port; SystemClock lives in Infrastructure.
  • A use case that needs to save an order depends on App\Application\Port\OrderRepository, an interface. The Doctrine class that implements it lives in Infrastructure and is bound at wiring time. The arrow points from Infrastructure inward to the port, which is exactly what the table allows.

Why the direction matters

The point of pointing imports inward is that the center never has a reason to change when the edge does. Swap Doctrine for raw PDO, Guzzle for the Symfony HTTP client, RabbitMQ for SQS — every one of those edits lands in Infrastructure. The Domain and Application files do not move, because nothing in them names the thing you replaced.

A port makes this concrete. The use case states its need in domain language.

<?php

declare(strict_types=1);

namespace App\Application\Port;

use App\Domain\Order\Order;
use App\Domain\Order\OrderId;

interface OrderRepository
{
    public function save(Order $order): void;
    public function findById(OrderId $id): ?Order;
}
Enter fullscreen mode Exit fullscreen mode

No EntityManager, no QueryBuilder, no SQL. The adapter that satisfies this interface is free to use all three, because it lives on the outside where vendor imports are legal.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Persistence\Doctrine;

use App\Application\Port\OrderRepository;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use Doctrine\ORM\EntityManagerInterface;

final readonly class DoctrineOrderRepository
    implements OrderRepository
{
    public function __construct(
        private EntityManagerInterface $em,
    ) {}

    public function save(Order $order): void
    {
        $this->em->persist($order);
    }

    public function findById(OrderId $id): ?Order
    {
        return $this->em->find(Order::class, $id->value);
    }
}
Enter fullscreen mode Exit fullscreen mode

Read the two files together. The use Doctrine\... line is legal here and illegal one folder over. That is the entire discipline, and folders alone will not hold it.

The CI check that enforces it

Nothing above survives contact with a deadline unless a machine rejects the violation. Two tools do this for PHP. Pick one.

Deptrac maps namespaces to layers and declares which layers may depend on which. A deptrac.yaml for this tree:

deptrac:
  paths:
    - ./src
  layers:
    - name: Domain
      collectors:
        - type: directory
          value: src/Domain/.*
    - name: Application
      collectors:
        - type: directory
          value: src/Application/.*
    - name: Infrastructure
      collectors:
        - type: directory
          value: src/Infrastructure/.*
  ruleset:
    Domain: ~
    Application:
      - Domain
    Infrastructure:
      - Domain
      - Application
Enter fullscreen mode Exit fullscreen mode

Domain: ~ means the domain may depend on nothing but itself. Run it in the pipeline:

vendor/bin/deptrac analyse --fail-on-uncovered
Enter fullscreen mode Exit fullscreen mode

Any import that crosses a boundary the ruleset forbids exits non-zero and breaks the build. --fail-on-uncovered also catches a namespace you forgot to assign to a layer, so a new top-level folder cannot sneak past unclassified.

PHPArkitect expresses the same rules as fluent PHP assertions if you prefer code over YAML:

Rule::allClasses()
    ->that(new ResideInOneOfTheseNamespaces('App\Domain'))
    ->should(new NotDependsOnTheseNamespaces(
        'App\Application',
        'App\Infrastructure',
        'Doctrine',
        'Symfony',
    ))
    ->because('the domain stays framework-free');
Enter fullscreen mode Exit fullscreen mode

If you want a zero-dependency smoke test before adding either tool, a grep in CI catches the loudest offenders:

if grep -rlE \
  'use (Doctrine|Symfony|GuzzleHttp|Illuminate)' \
  src/Domain src/Application; then
  echo "vendor import inside Domain/Application"
  exit 1
fi
Enter fullscreen mode Exit fullscreen mode

The grep is blunt. It misses transitive dependencies and the PSR carve-outs, so treat it as a stopgap while you wire up Deptrac. The static tool is the real gate. It reads the actual use graph, not a string match, and it fails the exact pull request that introduced the leak, while the reviewer still remembers the context.

Where to start

If you have an existing app, do not rewrite the tree in one branch. Create the three folders, move one aggregate and its use case inside, add the Deptrac config with only that slice covered, and turn on --fail-on-uncovered. The build starts guarding the boundary you just drew. Every following slice inherits the same gate for free, and the day someone adds use Doctrine to a domain class, the pipeline says no before a human has to.

Keeping the framework at the outer edge is a dependency direction, not a folder convention. The direction only holds when a machine checks it on every push. That inversion, the domain that depends on nothing and the framework that depends on everything, is the spine of what Decoupled PHP builds chapter by chapter: ports, adapters, use cases, and the migration path for a Laravel or Symfony app that has to keep shipping while it moves onto this shape.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)