From Layered to Clean Architecture
Understand why dependencies should point inward.
From Layered to Clean Architecture is a free PHP Academy lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the PHP Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Why Clean Architecture
You already know the classic three-layer PHP stack: Controller → Service → Repository → Database. It works, but the business logic ends up coupled to Eloquent, Doctrine, the HTTP request, and the framework lifecycle. Clean Architecture flips the dependency direction so your domain knows nothing about infrastructure. The reward: testable use cases, swappable adapters, and a codebase that survives framework upgrades.
The Dependency Rule
The single rule of Clean Architecture: source code dependencies point only inward. Inner circles (entities, use cases) must never reference outer circles (controllers, ORMs, frameworks). At runtime control flows outward via interfaces, but at compile/import time nothing inner imports anything outer.
- Entities: enterprise rules
- Use Cases: application rules
- Adapters: controllers, presenters, gateways
- Frameworks & Drivers: DB, HTTP, the web
A Coupled Layered Example
Here is the kind of service most PHP apps ship. Notice the domain logic is tangled with Eloquent and the HTTP response. You cannot unit-test the discount rule without a database and a framework.
<?php
class OrderService
{
public function place(Request $request)
{
$user = User::find($request->user_id); // Eloquent
$total = 0;
foreach ($request->items as $i) {
$total += Product::find($i['id'])->price * $i['qty'];
}
if ($user->is_vip) {
$total *= 0.9; // business rule trapped in infra code
}
Order::create(['user_id' => $user->id, 'total' => $total]);
return response()->json(['total' => $total]);
}
}Entities: Framework-Free Domain
An entity encodes enterprise-wide rules and depends on nothing. Plain PHP, no annotations, no base class from the ORM. It is fully constructible in a test.
<?php
final class Money
{
public function __construct(public readonly int $cents) {
if ($cents < 0) throw new InvalidArgumentException('negative money');
}
public function multiply(float $factor): self {
return new self((int) round($this->cents * $factor));
}
}
final class Order
{
/** @param array<int,int> $lineCents */
public function __construct(private array $lineCents, private bool $vip) {}
public function total(): Money {
$sum = array_sum($this->lineCents);
$money = new Money($sum);
return $this->vip ? $money->multiply(0.9) : $money;
}
}
echo (new Order([1000, 2000], true))->total()->cents, PHP_EOL; // 2700Use Cases Own the Workflow
A use case (interactor) orchestrates entities and talks to the outside world only through interfaces (ports). It receives a request DTO and returns a response DTO — never an HTTP object.
<?php
interface OrderRepository {
public function save(Order $order): void;
}
final class PlaceOrder
{
public function __construct(private OrderRepository $orders) {}
public function execute(array $lineCents, bool $vip): int {
$order = new Order($lineCents, $vip);
$this->orders->save($order);
return $order->total()->cents;
}
}The Boundary Is an Interface
The use case declares the OrderRepository interface it needs. The interface lives in the inner circle; the concrete Eloquent/Doctrine implementation lives outside and depends inward. This is the Dependency Inversion Principle applied at an architectural boundary.
Direction of source dependency: EloquentOrderRepository → OrderRepository (interface), never the reverse.
<?php
// Lives in infrastructure layer, points INWARD to the domain interface
final class EloquentOrderRepository implements OrderRepository
{
public function save(Order $order): void {
OrderModel::create(['total' => $order->total()->cents]);
}
}Testing Without Infrastructure
Because the use case depends on an interface, tests inject a fake. No database, no framework boot — microsecond-fast unit tests that assert pure business behavior.
<?php
final class InMemoryOrders implements OrderRepository {
public array $saved = [];
public function save(Order $o): void { $this->saved[] = $o; }
}
$repo = new InMemoryOrders();
$useCase = new PlaceOrder($repo);
$total = $useCase->execute([1000, 2000], true);
assert($total === 2700);
assert(count($repo->saved) === 1);
echo "PASS total=$total saved=" . count($repo->saved) . PHP_EOL;Controllers Become Thin Adapters
The controller is now an adapter: it translates HTTP into a use-case call and the result into HTTP. It holds no business rules. Swap REST for CLI or a queue worker and the use case is untouched.
<?php
final class OrderController
{
public function __construct(private PlaceOrder $placeOrder) {}
public function store(Request $request): JsonResponse {
$total = $this->placeOrder->execute(
lineCents: $request->input('lineCents'),
vip: (bool) $request->input('vip'),
);
return new JsonResponse(['total' => $total], 201);
}
}Screaming Architecture
Folder structure should scream the domain, not the framework. Avoid Controllers/, Models/ at the top. Organize by capability so a newcomer sees what the app does.
src/Ordering/Domain/— entities, value objectssrc/Ordering/Application/— use cases, port interfacessrc/Ordering/Infrastructure/— Eloquent repos, HTTP controllers
Each bounded context is a top-level folder; the framework lives at the edges.
Enforcing the Dependency Rule
Discipline erodes without tooling. Use deptrac or phparkitect in CI to fail the build when Domain imports Infrastructure. The rule becomes a compile-time guarantee rather than a code-review hope.
# deptrac.yaml
deptrac:
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: [] # Domain may depend on nothing
Application: [Domain]
Infrastructure: [Application, Domain]Crossing Boundaries with DTOs
To keep entities from leaking outward, data that crosses a boundary travels as a simple DTO, not as an entity or an ORM model. The use case returns a flat structure the adapter can serialize, so the domain object never escapes the core and the outer layer never gains a handle on internal state.
<?php
final class OrderSummary // boundary DTO, no behavior, no domain types
{
public function __construct(
public readonly string $orderId,
public readonly int $totalCents,
) {}
}
final class PlaceOrderV2 {
public function __construct(private OrderRepository $orders) {}
public function execute(array $lineCents, bool $vip): OrderSummary {
$order = new Order($lineCents, $vip);
$this->orders->save($order);
return new OrderSummary('ord_1', $order->total()->cents);
}
}Quick Check
Which dependency direction is allowed under the Dependency Rule?
Recap
You moved from a coupled layered stack to Clean Architecture:
- The Dependency Rule: source dependencies point only inward.
- Entities hold enterprise rules in framework-free PHP.
- Use cases orchestrate via port interfaces, returning DTOs not HTTP.
- Controllers and ORM repos are outer adapters that depend inward (DIP).
- Structure should scream the domain, and tools like deptrac enforce the rule in CI.
Frequently asked questions
Is the “From Layered to Clean Architecture” lesson free?
Yes — the full text of “From Layered to Clean Architecture” is free to read here on the web, and the PHP Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the PHP Academy course, upgrade to CoddyKit PRO.
What will I learn in “From Layered to Clean Architecture”?
Understand why dependencies should point inward. You practise PHP Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start PHP Academy?
No prior experience is required. PHP Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “From Layered to Clean Architecture” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this PHP Academy lesson?
Yes. Every PHP Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- From Layered to Clean Architecture
- Ports and Adapters Explained
- Use Cases and Application Services
- Dependency Inversion in Practice