DEV Community

Cover image for Composer Autoloading for Hexagonal PHP: A Namespace Plan That Holds
Gabriel Anhaia
Gabriel Anhaia

Posted on

Composer Autoloading for Hexagonal PHP: A Namespace Plan That Holds


You decide your Laravel service is going to have a domain layer. You read a hexagonal post over coffee, you sketch a folder tree on a sticky note, and by lunch you've committed an app/Domain/ directory with a single Order.php in it.

The class lives at App\Domain\Order. The autoloader can't find it. php artisan blows up. You add a PSR-4 entry, the IDE complains about duplicate roots, Pint reformats half your namespaces, and now the colleague who joined last week is asking why there are three different ways to find a class.

This is the part of hexagonal nobody writes about. The architecture diagrams stop at the ring boundary. The wiring stops at "put your domain in a folder." What actually decides whether the layout works is a 30-line composer.json block, and it has to coexist with whatever the framework wants app/ or src/ to mean.

This post is the layout that holds. Three top-level namespaces, one autoload block, a folder tree that doesn't fight Laravel or Symfony, and a deptrac config that fails CI the day someone imports Eloquent from the domain.

The namespace plan

Three namespaces. That's the whole map.

App\Domain\*          // entities, value objects, domain services, no infra
App\Application\*     // use cases, ports (interfaces the domain consumes)
App\Infrastructure\*  // adapters: HTTP, persistence, queue, external APIs
Enter fullscreen mode Exit fullscreen mode

App\Domain knows nothing about the framework. It depends on PHP's standard library and ext-* only. No Illuminate\*, no Symfony\Component\*, no Doctrine\ORM\*. Plain classes, enums, value objects.

App\Application knows the domain. It defines the ports the domain needs (OrderRepository, Clock, PaymentGateway) as interfaces, and the use cases (PlaceOrder, CancelSubscription) as one class per verb. It does not know what database or HTTP layer is on the other side of those ports.

App\Infrastructure knows the framework. Eloquent models, Doctrine entities, Symfony controllers, queue workers, Guzzle clients. Every class here implements an App\Application port or wires one of its use cases into a delivery mechanism.

Direction of dependency, always: Infrastructure → Application → Domain. Never the other way. If a domain file has use Illuminate\ in it, the architecture is leaking.

Three-namespace hexagonal layout: Domain, Application, Infrastructure in concentric rings with arrows pointing inward

The composer.json that makes it work

Here is the autoload block. It works the same way for Laravel 11 and Symfony 7 — the only difference is whether the root is app/ or src/.

Laravel (root = app/)

{
  "autoload": {
    "psr-4": {
      "App\\": "app/",
      "App\\Domain\\": "app/Domain/",
      "App\\Application\\": "app/Application/",
      "App\\Infrastructure\\": "app/Infrastructure/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/",
      "Tests\\Domain\\": "tests/Domain/",
      "Tests\\Application\\": "tests/Application/",
      "Tests\\Infrastructure\\": "tests/Infrastructure/"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The first three lines under psr-4 are technically redundant — App\\ already covers everything under app/. They are there for two reasons: documentation (anyone opening composer.json sees the layout in 4 lines) and partial-directory tooling. Some refactor tools and IDEs read the most-specific PSR-4 entry to determine the root for a given namespace. Keep the entries; the autoloader handles the overlap.

Symfony (root = src/)

{
  "autoload": {
    "psr-4": {
      "App\\": "src/",
      "App\\Domain\\": "src/Domain/",
      "App\\Application\\": "src/Application/",
      "App\\Infrastructure\\": "src/Infrastructure/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "App\\Tests\\": "tests/"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Same shape. Symfony picked src/ as the convention; Laravel picked app/. PSR-4 doesn't care.

Run composer dump-autoload -o once after editing. The -o flag generates the optimized classmap, which is what production should ship.

Why not split into a separate package?

You will see hexagonal tutorials that put the domain into its own Composer package under packages/domain with a path repository entry. This works. It enforces the boundary harder than namespaces alone. It also adds a packages/domain/composer.json, a composer require step, and a release dance for every domain change.

For one team and one repo, three namespaces inside one autoload block is enough. The boundary check moves into deptrac (see below), where it costs nothing. Split into packages on the day you have a second consumer of the domain: a CLI tool, a second service, a public SDK. Not before.

The directory tree

Here is what app/ (Laravel) or src/ (Symfony) looks like after the namespaces are in place. I'm showing a small e-commerce service with one bounded slice: ordering.

app/
├── Console/                     # Laravel keeps this
├── Http/                        # Laravel keeps this — controllers stay
├── Providers/                   # Laravel keeps this
├── Models/                      # Eloquent models live here, untouched
│
├── Domain/
│   └── Ordering/
│       ├── Order.php            # pure PHP class, no framework
│       ├── OrderId.php          # value object
│       ├── LineItem.php
│       ├── Money.php
│       └── OrderStatus.php      # enum
│
├── Application/
│   └── Ordering/
│       ├── Port/
│       │   ├── OrderRepository.php       # interface
│       │   ├── PaymentGateway.php        # interface
│       │   └── Clock.php                 # interface
│       └── UseCase/
│           ├── PlaceOrder.php
│           └── CancelOrder.php
│
└── Infrastructure/
    └── Ordering/
        ├── Persistence/
        │   ├── EloquentOrderRepository.php   # implements Port\OrderRepository
        │   └── OrderEloquentModel.php        # extends Eloquent\Model
        ├── Http/
        │   ├── PlaceOrderController.php
        │   └── PlaceOrderRequest.php
        ├── Payment/
        │   └── StripePaymentGateway.php      # implements Port\PaymentGateway
        └── Clock/
            └── SystemClock.php               # implements Port\Clock
Enter fullscreen mode Exit fullscreen mode

Notice what stays where Laravel expects it. Http/, Console/, Providers/, Models/ keep their default locations. The framework's introspection (Artisan, route discovery, model resolution) keeps working. You are not renaming anything Laravel ships with. You are adding three siblings: Domain/, Application/, Infrastructure/.

For Symfony, the same tree lives under src/. Controller/, Entity/, Kernel.php keep their default Symfony locations. The three architectural siblings sit next to them.

The slice (Ordering/) repeats inside each layer. This is the part most people skip. A flat Domain/Order.php, Domain/Customer.php, Domain/Invoice.php works for ten classes. By a hundred, you can't find anything. Slice by business capability inside each layer (Domain/Ordering/, Domain/Billing/, Domain/Catalog/). The same word in Application/Ordering/ and Infrastructure/Ordering/ tells you the three files belong together.

Wiring the adapter to the port

The Eloquent model stays where Laravel wants it (app/Models/) and gets wrapped by an infrastructure adapter that implements the application port:

<?php

declare(strict_types=1);

namespace App\Infrastructure\Ordering\Persistence;

use App\Application\Ordering\Port\OrderRepository;
use App\Domain\Ordering\CustomerId;
use App\Domain\Ordering\Money;
use App\Domain\Ordering\Order;
use App\Domain\Ordering\OrderId;
use App\Domain\Ordering\OrderStatus;
use App\Models\OrderEloquentModel;

final class EloquentOrderRepository implements OrderRepository
{
    public function save(Order $order): void
    {
        $model = OrderEloquentModel::firstOrNew(
            ['id' => $order->id()->value()]
        );
        $model->customer_id = $order->customerId()->value();
        $model->total_cents = $order->total()->cents();
        $model->status = $order->status()->value;
        $model->save();
    }

    public function byId(OrderId $id): ?Order
    {
        $model = OrderEloquentModel::find($id->value());
        return $model === null
            ? null
            : $this->toDomain($model);
    }

    private function toDomain(OrderEloquentModel $m): Order
    {
        return new Order(
            new OrderId($m->id),
            new CustomerId($m->customer_id),
            Money::fromCents($m->total_cents),
            OrderStatus::from($m->status),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The mapping is the boring part. It is also the part that earns its keep. The day someone proposes moving from Eloquent to Doctrine, or from Postgres to DynamoDB, this file is the only one that changes. The use case, the domain, the controller: all untouched.

Bind the port to the adapter in a service provider (Laravel) or a service config (Symfony). For Laravel:

<?php

namespace App\Providers;

use App\Application\Ordering\Port\Clock;
use App\Application\Ordering\Port\OrderRepository;
use App\Infrastructure\Ordering\Clock\SystemClock;
use App\Infrastructure\Ordering\Persistence\EloquentOrderRepository;
use Illuminate\Support\ServiceProvider;

final class HexagonalServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
        $this->app->bind(Clock::class, SystemClock::class);
    }
}
Enter fullscreen mode Exit fullscreen mode

That provider is the only place in the codebase that knows both sides of every port. Register it in bootstrap/providers.php (Laravel 11) and the wiring is done.

Enforcing the dependency rule in CI

Namespaces are a contract written in your head. CI is the contract that actually holds. Two tools do the job — pick one.

Deptrac

deptrac.yaml at the repo root:

deptrac:
  paths:
    - ./app
  layers:
    - name: Domain
      collectors:
        - type: classLike
          value: ^App\\Domain\\.*
    - name: Application
      collectors:
        - type: classLike
          value: ^App\\Application\\.*
    - name: Infrastructure
      collectors:
        - type: classLike
          value: ^App\\Infrastructure\\.*
    - name: Framework
      collectors:
        - type: classLike
          value: ^(Illuminate|Symfony|Doctrine)\\.*

  ruleset:
    Domain: []
    Application:
      - Domain
    Infrastructure:
      - Application
      - Domain
      - Framework

  skip_violations: { }
Enter fullscreen mode Exit fullscreen mode

Read the ruleset bottom to top. Domain: [] means the domain depends on nothing, and Application may depend on Domain and only on Domain. Infrastructure may depend on everything except itself. Framework is not in any allowed list for Domain or Application, and that's the boundary.

Run it: vendor/bin/deptrac analyse. Wire it into CI as a failing step. The first time you run it on an existing codebase you will get a wall of red. That's the architecture telling you the truth.

PHPArkitect (alternative)

If you prefer rules expressed in PHP rather than YAML:

<?php

use Arkitect\ClassSet;
use Arkitect\CLI\Config;
use Arkitect\Rules\Rule;
use Arkitect\Expression\ForClasses\NotDependsOnTheseNamespaces;
use Arkitect\Expression\ForClasses\ResideInOneOfTheseNamespaces;

return static function (Config $config): void {
    $classSet = ClassSet::fromDir(__DIR__ . '/app');

    $domainIsClean = Rule::allClasses()
        ->that(new ResideInOneOfTheseNamespaces('App\\Domain'))
        ->should(new NotDependsOnTheseNamespaces(
            'Illuminate',
            'Symfony',
            'Doctrine',
            'App\\Application',
            'App\\Infrastructure',
        ))
        ->because('domain must not depend on the framework or outer layers');

    $applicationStaysInside = Rule::allClasses()
        ->that(new ResideInOneOfTheseNamespaces('App\\Application'))
        ->should(new NotDependsOnTheseNamespaces(
            'Illuminate',
            'Symfony',
            'Doctrine',
            'App\\Infrastructure',
        ))
        ->because('application may depend on domain only');

    $config->add($classSet, $domainIsClean, $applicationStaysInside);
};
Enter fullscreen mode Exit fullscreen mode

Run: vendor/bin/phparkitect check. Same outcome, different syntax.

Either tool, the rule is the same: the day someone adds use Illuminate\Database\Eloquent\Model; to a domain class, the build goes red and the PR doesn't merge. The architecture stops being a vibe and becomes a check.

CI pipeline diagram: PR opens, composer install runs, deptrac analyses layers, red X on the domain-imports-Eloquent step

Things that go wrong, and the one-line fix

The autoloader can't find a class you just created. Run composer dump-autoload. PSR-4 is filesystem-driven, and Composer caches the classmap. New files don't appear until you regenerate.

Pint or PHP-CS-Fixer keeps reformatting your namespaces. Both tools default to PSR-12, which is compatible with this layout. If your tool is fighting you, it's likely a custom psr_autoloading rule that expects a single namespace root. Drop it; PSR-4 covers the case natively.

Laravel's route file complains it can't find a controller. Laravel 11's bootstrap/app.php configures routing. If you moved controllers out of app/Http/Controllers into app/Infrastructure/Ordering/Http, either reference them by full class name in the routes file or extend the Route::controller() namespace prefix. Don't try to teach Laravel's discovery about three roots — be explicit in the route file.

The IDE shows duplicate package roots. PhpStorm reads every PSR-4 entry as a source root. The redundant App\Domain\\ entries above will show up next to the App\\ one. This is cosmetic. If it bothers you, remove the sub-entries; you only lose the documentation effect.

Symfony auto-wiring picks the wrong adapter. Symfony's autowire binds interfaces to concrete classes by name. If you have two adapters for OrderRepository (an Eloquent one and an in-memory one for tests), you must bind explicitly in config/services.yaml:

services:
    App\Application\Ordering\Port\OrderRepository:
        alias: App\Infrastructure\Ordering\Persistence\DoctrineOrderRepository
Enter fullscreen mode Exit fullscreen mode

Why this layout pays back

The autoload block is twelve lines. The directory tree adds three folders next to the ones your framework already wanted. The deptrac config is twenty-five lines. That's the whole tax.

What it buys is the property that on every code review, every PR, every refactor, the question "where does this go?" has one answer. A new use case lands in Application/<slice>/UseCase/. A new external API lands in Infrastructure/<slice>/<service>/. A new value object lands in Domain/<slice>/. The autoloader resolves it without ceremony, and the CI check fails the day someone tries to cheat. The framework doesn't notice anything has changed, because you didn't take anything away from it. You just stopped pouring business logic into it.

Five years in, the framework upgrade is a composer require and a controller rewrite. The domain doesn't move.


If this was useful

The full layout — slicing, value objects, use case orchestration, the migration playbook from a framework-coupled service to this shape — is the spine of Decoupled PHP. It walks the same three-namespace plan from a one-table CRUD endpoint up to a multi-adapter service with queues, external APIs, and contract tests. If you're a backend PHP engineer who keeps fighting your framework, that book is the one.

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)