Skip to content

Dependency Direction Rules ​

Clear dependency direction is the foundation of architectural stability


Table of Contents ​


Dependency Direction Diagram ​

┌─────────────────────────────────────────────────────────┐
│                      Controller Layer                    │
│                   (HTTP Input/Output)                    │
└────────────────────────┬────────────────────────────────┘
                         │ depends on
                         ↓
┌─────────────────────────────────────────────────────────┐
│                      Service Layer                       │
│                  (Use Case Orchestration)                │
└────────┬────────────────────────────────────────────────┘
         │ depends on
         ↓
┌────────────────────────┐        ┌─────────────────────┐
│    Domain Layer        │←───────│  Contract Layer     │
│  (Business Logic)      │ impl   │  (Interfaces)       │
└────────────────────────┘        └──────────┬──────────┘
         ↑                                    ↑
         │ depends on                         │ implements
         │                                    │
┌────────────────────────────────────────────┴────────────┐
│                 Infrastructure Layer                     │
│          (Repository, Gateway, External Services)        │
└──────────────────────────────────────────────────────────┘

Allowed Dependencies ​

Controller → Service ​

Allowed: Controller can depend on Service layer

php
<?php

declare(strict_types=1);

namespace app\controller\api\v1;

use app\service\order\CreateOrderService;
use support\Request;
use support\Response;

final class OrderController
{
    public function __construct(
        private readonly CreateOrderService $createOrderService
    ) {
    }

    public function create(Request $request): Response
    {
        $order = $this->createOrderService->handle(
            userId: $request->user()->id,
            items: $request->post('items'),
            shippingAddress: $request->post('shipping_address')
        );

        return json(['data' => $order]);
    }
}

Service → Domain + Contract ​

Allowed: Service can depend on Domain entities and Contract interfaces

php
<?php

declare(strict_types=1);

namespace app\service\order;

use app\contract\repository\OrderRepositoryInterface;
use app\contract\gateway\PaymentGatewayInterface;
use app\domain\order\entity\Order;
use support\Db;

final class CreateOrderService
{
    public function __construct(
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly PaymentGatewayInterface $paymentGateway
    ) {
    }

    public function handle(int $userId, array $items, array $shippingAddress): Order
    {
        return Db::transaction(function () use ($userId, $items, $shippingAddress) {
            $order = Order::create($userId, $items, $shippingAddress);
            $this->orderRepository->save($order);
            $this->paymentGateway->createPaymentIntent($order);
            return $order;
        });
    }
}

Infrastructure → Contract + Domain ​

Allowed: Infrastructure implements Contract interfaces, can depend on Domain entities

php
<?php

declare(strict_types=1);

namespace app\infrastructure\repository\eloquent;

use app\contract\repository\OrderRepositoryInterface;
use app\domain\order\entity\Order;
use app\domain\order\vo\Money;
use app\domain\order\enum\OrderStatus;
use app\model\eloquent\Order as OrderModel;

final class EloquentOrderRepository implements OrderRepositoryInterface
{
    public function findById(int $id): ?Order
    {
        $model = OrderModel::find($id);
        return $model ? $this->toDomain($model) : null;
    }

    public function save(Order $order): void
    {
        $model = OrderModel::findOrNew($order->id());
        $model->user_id = $order->userId();
        $model->total_amount = $order->totalAmount()->toDollars();
        $model->status = $order->status()->value;
        $model->save();
    }

    private function toDomain(OrderModel $model): Order
    {
        return Order::reconstitute(
            id: $model->id,
            userId: $model->user_id,
            totalAmount: Money::fromDollars($model->total_amount),
            status: OrderStatus::from($model->status),
            createdAt: new \DateTimeImmutable($model->created_at)
        );
    }
}

Domain → Domain (Same Context) ​

Allowed: Domain objects within the same bounded context can depend on each other

php
<?php

declare(strict_types=1);

namespace app\domain\order\entity;

use app\domain\order\vo\Money;
use app\domain\order\enum\OrderStatus;

final class Order
{
    private function __construct(
        private readonly int $id,
        private Money $totalAmount,
        private OrderStatus $status
    ) {
    }

    public function calculateTotal(array $items): void
    {
        $total = array_reduce(
            $items,
            fn (Money $carry, array $item) => $carry->add(
                Money::fromCents($item['price'] * $item['quantity'])
            ),
            Money::zero()
        );

        $this->totalAmount = $total;
    }
}

Forbidden Dependencies ​

Domain → Framework ​

Forbidden: Domain layer cannot depend on framework classes

php
<?php

// Bad Example - Domain depends on framework
namespace app\domain\order\entity;

use support\Db;  // Cannot depend on Webman framework
use support\Redis;  // Cannot depend on framework

final class Order
{
    public function save(): void
    {
        // Domain should not directly access database
        Db::table('orders')->insert([...]);
    }
}
php
<?php

// Good Example - Through Repository interface
namespace app\domain\order\entity;

final class Order
{
    // Domain only contains business logic, not responsible for persistence
    public function calculateTotal(): void
    {
        // Pure business logic
    }
}

// Persistence is done by Service layer calling Repository
namespace app\service\order;

use app\contract\repository\OrderRepositoryInterface;

final class CreateOrderService
{
    public function __construct(
        private readonly OrderRepositoryInterface $orderRepository
    ) {
    }

    public function handle(): void
    {
        $order = Order::create(...);
        $this->orderRepository->save($order);  // Persist through interface
    }
}

Domain → Model ​

Forbidden: Domain layer cannot depend on ORM Model

php
<?php

// Bad Example
namespace app\domain\order\entity;

use app\model\eloquent\Order as OrderModel;  // Cannot depend on Model

final class Order
{
    public function toModel(): OrderModel
    {
        // Domain should not know about ORM existence
        return new OrderModel([...]);
    }
}
php
<?php

// Good Example - Repository handles conversion
namespace app\infrastructure\repository\eloquent;

use app\domain\order\entity\Order;
use app\model\eloquent\Order as OrderModel;

final class EloquentOrderRepository
{
    public function save(Order $order): void
    {
        // Repository handles Domain to Model conversion
        $model = OrderModel::findOrNew($order->id());
        $model->user_id = $order->userId();
        $model->save();
    }
}

Domain → Infrastructure ​

Forbidden: Domain layer cannot depend on Infrastructure implementations

php
<?php

// Bad Example
namespace app\domain\order\entity;

use app\infrastructure\gateway\payment\StripePaymentGateway;

final class Order
{
    public function pay(): void
    {
        // Domain should not depend on concrete implementations
        $gateway = new StripePaymentGateway();
        $gateway->charge($this->totalAmount);
    }
}
php
<?php

// Good Example - Through Service layer
namespace app\service\order;

use app\contract\gateway\PaymentGatewayInterface;
use app\domain\order\entity\Order;

final class PayOrderService
{
    public function __construct(
        private readonly PaymentGatewayInterface $paymentGateway
    ) {
    }

    public function handle(Order $order): void
    {
        // Service layer calls external services
        $this->paymentGateway->charge($order->totalAmount());
    }
}

Controller → Model ​

Forbidden: Controller cannot directly access Model

php
<?php

// Bad Example
namespace app\controller\api\v1;

use app\model\eloquent\Order;
use support\Request;

final class OrderController
{
    public function index(Request $request): array
    {
        // Controller should not directly query database
        return Order::where('user_id', $request->user()->id)->get();
    }
}
php
<?php

// Good Example - Through Service layer
namespace app\controller\api\v1;

use app\service\order\GetUserOrdersService;
use support\Request;

final class OrderController
{
    public function __construct(
        private readonly GetUserOrdersService $getUserOrdersService
    ) {
    }

    public function index(Request $request): array
    {
        // Get data through Service layer
        return $this->getUserOrdersService->handle($request->user()->id);
    }
}

Controller → Infrastructure ​

Forbidden: Controller cannot directly depend on Infrastructure

php
<?php

// Bad Example
namespace app\controller\api\v1;

use app\infrastructure\repository\eloquent\EloquentOrderRepository;

final class OrderController
{
    public function __construct(
        private readonly EloquentOrderRepository $orderRepository
    ) {
    }
}
php
<?php

// Good Example - Depend on Service
namespace app\controller\api\v1;

use app\service\order\CreateOrderService;

final class OrderController
{
    public function __construct(
        private readonly CreateOrderService $createOrderService
    ) {
    }
}

Enforcement Strategies ​

1. PHPStan Rules ​

Create phpstan.neon configuration file:

neon
parameters:
    level: 8
    paths:
        - app

    # Forbid Domain layer from depending on framework
    ignoreErrors:
        -
            message: '#app\\domain\\.*# cannot depend on #support\\.*#'
            path: app/domain
        -
            message: '#app\\domain\\.*# cannot depend on #app\\model\\.*#'
            path: app/domain
        -
            message: '#app\\domain\\.*# cannot depend on #app\\infrastructure\\.*#'
            path: app/domain

    # Forbid Controller from directly depending on Model
    ignoreErrors:
        -
            message: '#app\\controller\\.*# cannot depend on #app\\model\\.*#'
            path: app/controller
        -
            message: '#app\\controller\\.*# cannot depend on #app\\infrastructure\\.*#'
            path: app/controller

Run check:

bash
composer require --dev phpstan/phpstan
vendor/bin/phpstan analyse

2. Rector Auto-refactoring ​

Create rector.php configuration:

php
<?php

declare(strict_types=1);

use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([
        __DIR__ . '/app',
    ])
    ->withRules([
        // Auto-detect and report code violating dependency rules
    ]);

3. Code Review Checklist ​

Check during Pull Request:

  • [ ] Does Domain layer depend on framework classes?
  • [ ] Does Domain layer depend on Model?
  • [ ] Does Controller directly access Model?
  • [ ] Does Controller contain business logic?
  • [ ] Does Service layer correctly use interfaces?
  • [ ] Does Infrastructure implement Contract interfaces?

4. Unit Test Verification ​

Domain layer should be testable independently without starting the framework:

php
<?php

declare(strict_types=1);

namespace tests\Unit\Domain\Order;

use app\domain\order\entity\Order;
use app\domain\order\values\Money;

test('order calculates total correctly', function () {
    $order = Order::create(
        userId: 1,
        items: [
            ['price' => 1000, 'quantity' => 2],
            ['price' => 500, 'quantity' => 1],
        ],
        shippingAddress: []
    );

    $order->calculateTotal();

    expect($order->totalAmount())->toEqual(Money::fromCents(2500));
});

Code Examples ​

Complete Example: Order Creation Flow ​

1. Controller Layer ​

php
<?php

declare(strict_types=1);

namespace app\controller\api\v1;

use app\service\order\CreateOrderService;
use support\Request;
use support\Response;

final class OrderController
{
    public function __construct(
        private readonly CreateOrderService $createOrderService
    ) {
    }

    public function create(Request $request): Response
    {
        $validated = $this->validate($request, [
            'items' => 'required|array',
            'shipping_address' => 'required|array',
        ]);

        $order = $this->createOrderService->handle(
            userId: $request->user()->id,
            items: $validated['items'],
            shippingAddress: $validated['shipping_address']
        );

        return json(['data' => $order->toArray()]);
    }
}

2. Service Layer ​

php
<?php

declare(strict_types=1);

namespace app\service\order;

use app\contract\repository\OrderRepositoryInterface;
use app\contract\repository\UserRepositoryInterface;
use app\domain\order\entity\Order;
use support\Db;

final class CreateOrderService
{
    public function __construct(
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly UserRepositoryInterface $userRepository
    ) {
    }

    public function handle(int $userId, array $items, array $shippingAddress): Order
    {
        return Db::transaction(function () use ($userId, $items, $shippingAddress) {
            $user = $this->userRepository->findById($userId);

            $order = Order::create($userId, $items, $shippingAddress);
            $order->calculateTotal();
            $order->validateInventory();

            $this->orderRepository->save($order);

            return $order;
        });
    }
}

3. Domain Layer ​

php
<?php

declare(strict_types=1);

namespace app\domain\order\entity;

use app\domain\order\vo\Money;
use app\domain\order\enum\OrderStatus;
use app\domain\order\exception\InvalidOrderException;

final class Order
{
    private function __construct(
        private readonly int $id,
        private readonly int $userId,
        private array $items,
        private Money $totalAmount,
        private OrderStatus $status
    ) {
    }

    public static function create(int $userId, array $items, array $shippingAddress): self
    {
        if (empty($items)) {
            throw new InvalidOrderException('Order must have at least one item');
        }

        return new self(
            id: 0,
            userId: $userId,
            items: $items,
            totalAmount: Money::zero(),
            status: OrderStatus::Pending
        );
    }

    public function calculateTotal(): void
    {
        $total = array_reduce(
            $this->items,
            fn (Money $carry, array $item) => $carry->add(
                Money::fromCents($item['price'] * $item['quantity'])
            ),
            Money::zero()
        );

        $this->totalAmount = $total;
    }

    public function validateInventory(): void
    {
        // Business rule validation
    }

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

    public function userId(): int
    {
        return $this->userId;
    }

    public function totalAmount(): Money
    {
        return $this->totalAmount;
    }

    public function status(): OrderStatus
    {
        return $this->status;
    }
}

4. Contract Layer ​

php
<?php

declare(strict_types=1);

namespace app\contract\repository;

use app\domain\order\entity\Order;

interface OrderRepositoryInterface
{
    public function findById(int $id): ?Order;

    public function save(Order $order): void;

    public function delete(Order $order): void;
}

5. Infrastructure Layer ​

php
<?php

declare(strict_types=1);

namespace app\infrastructure\repository\eloquent;

use app\contract\repository\OrderRepositoryInterface;
use app\domain\order\entity\Order;
use app\domain\order\vo\Money;
use app\domain\order\enum\OrderStatus;
use app\model\eloquent\Order as OrderModel;

final class EloquentOrderRepository implements OrderRepositoryInterface
{
    public function findById(int $id): ?Order
    {
        $model = OrderModel::find($id);
        return $model ? $this->toDomain($model) : null;
    }

    public function save(Order $order): void
    {
        $model = OrderModel::findOrNew($order->id());
        $model->user_id = $order->userId();
        $model->total_amount = $order->totalAmount()->toDollars();
        $model->status = $order->status()->value;
        $model->save();
    }

    public function delete(Order $order): void
    {
        OrderModel::destroy($order->id());
    }

    private function toDomain(OrderModel $model): Order
    {
        return Order::reconstitute(
            id: $model->id,
            userId: $model->user_id,
            totalAmount: Money::fromDollars($model->total_amount),
            status: OrderStatus::from($model->status),
            createdAt: new \DateTimeImmutable($model->created_at)
        );
    }
}

Dependency Injection Configuration ​

Configure dependency injection in config/container.php:

php
<?php

declare(strict_types=1);

use app\contract\repository\OrderRepositoryInterface;
use app\infrastructure\repository\eloquent\EloquentOrderRepository;

return [
    // Bind interface to implementation
    OrderRepositoryInterface::class => DI\autowire(EloquentOrderRepository::class),
];

Best Practices ​

DO ​

  1. Controller only depends on Service
  2. Service depends on Contract interfaces, not concrete implementations
  3. Domain stays pure, no framework dependencies
  4. Infrastructure implements Contract interfaces
  5. Use dependency injection container to manage dependencies

DON'T ​

  1. Don't use framework classes in Domain layer
  2. Don't directly access Model in Controller
  3. Don't depend on concrete Infrastructure implementations in Service layer
  4. Don't make cross-layer calls (e.g., Controller to Infrastructure)
  5. Don't create circular dependencies