Clean Architecture is not a framework, a folder layout you copy, or a library you install. It is one idea about which direction your code is allowed to depend, drawn as a set of concentric circles by Robert C. Martin in 2012.
This note strips it to the essentials: the circles, the single rule that makes them work, and one real feature — placing an order in a small e-commerce app — built the Clean way in PHP. It reads in about 10–15 minutes. By the end you should be able to explain where a piece of code belongs and why.
The one-sentence definition
Clean Architecture organises code into layers so that business rules do not depend on frameworks, databases, or the web — those details depend on the business rules, never the other way around.
Everything below is a consequence of that sentence.
The circles
| Layer | What lives here | Knows about |
|---|---|---|
| Entities | Core domain objects and rules true for the whole business (an Order, a Money value object, “an order total is the sum of its lines”). |
Nothing external. Plain language objects. |
| Use Cases | One class per application action (PlaceOrder, CancelOrder). Orchestrates entities to fulfil a request. |
Entities, and interfaces it defines for what it needs. |
| Interface Adapters | Controllers, request/response mappers, repository implementations, gateway implementations. Translates between the outside world and the use cases. | Use cases and entities. |
| Frameworks & Drivers | Laravel, the HTTP kernel, Eloquent, MySQL, the Stripe SDK, the queue. | Everything — but nothing depends on it from the inside. |
The Dependency Rule
Source code dependencies must point only inward, toward higher-level policy.
A class in an inner circle must never mention the name of a class in an outer circle. PlaceOrder (use case) may not reference OrderController, EloquentOrderRepository, Request, or DB. The dependency arrow always goes from concrete detail toward abstract rule.
When an inner layer needs something from the outside — “save this order somewhere” — it does not call the database. It declares an interface it owns, and an outer layer implements it. This is the Dependency Inversion Principle, and it is the mechanism that lets the circles hold.
Real-world use case: “Place an order”
A customer submits a cart. The system must: check every product is in stock, calculate the total, charge the card, save the order, and email a receipt. If the charge fails, nothing is saved.
We will build this feature from the inside out.
1. Entities — the domain, no framework in sight
// domain/Order.php
final class Order
{
/** @param OrderLine[] $lines */
private function __construct(
public readonly OrderId $id,
public readonly CustomerId $customerId,
public readonly array $lines,
private OrderStatus $status,
) {}
public static function place(OrderId $id, CustomerId $customerId, array $lines): self
{
if ($lines === []) {
throw new DomainException('An order must have at least one line.');
}
return new self($id, $customerId, $lines, OrderStatus::Pending);
}
public function total(): Money
{
return array_reduce(
$this->lines,
fn (Money $carry, OrderLine $l) => $carry->add($l->subtotal()),
Money::zero('USD'),
);
}
public function markPaid(): void
{
$this->status = OrderStatus::Paid;
}
}
Order has no idea it will be stored in MySQL or created from an HTTP request. It only knows what an order is and what makes one valid. You can test total() and the “must have a line” rule with zero setup.
2. Ports — interfaces the use case owns
The use case needs to load products, persist orders, take payment, and notify the customer. It defines what it needs and nothing more:
// application/ports/ProductCatalog.php
interface ProductCatalog
{
public function find(ProductId $id): ?Product;
}
// application/ports/OrderRepository.php
interface OrderRepository
{
public function save(Order $order): void;
}
// application/ports/PaymentGateway.php
interface PaymentGateway
{
public function charge(CustomerId $customer, Money $amount): PaymentResult;
}
// application/ports/OrderNotifier.php
interface OrderNotifier
{
public function orderPlaced(Order $order): void;
}
These live with the use case, in the application layer. They are phrased in domain terms — charge(CustomerId, Money), not createStripePaymentIntent(array $params).
3. The Use Case — application business rules
// application/PlaceOrder.php
final class PlaceOrder
{
public function __construct(
private ProductCatalog $catalog,
private OrderRepository $orders,
private PaymentGateway $payments,
private OrderNotifier $notifier,
) {}
public function handle(PlaceOrderCommand $command): OrderId
{
$lines = [];
foreach ($command->items as $item) {
$product = $this->catalog->find($item->productId)
?? throw new ProductNotFound($item->productId);
if (! $product->hasStock($item->quantity)) {
throw new OutOfStock($product->id);
}
$lines[] = new OrderLine($product->id, $product->price, $item->quantity);
}
$order = Order::place(OrderId::generate(), $command->customerId, $lines);
$result = $this->payments->charge($command->customerId, $order->total());
if (! $result->successful()) {
throw new PaymentDeclined($result->reason());
}
$order->markPaid();
$this->orders->save($order);
$this->notifier->orderPlaced($order);
return $order->id;
}
}
Read it top to bottom: it is the business process in plain terms. No Request, no DB::transaction, no Mail::to(), no Stripe. Those words never appear in this layer. Swap MySQL for DynamoDB, Stripe for PayPal, HTTP for a CLI command — this class does not change.
4. Interface Adapters — translate the outside world
The controller turns an HTTP request into a command, calls the use case, and turns the result into a response. That is all it does.
// adapters/http/PlaceOrderController.php
final class PlaceOrderController
{
public function __construct(private PlaceOrder $placeOrder) {}
public function __invoke(PlaceOrderRequest $request): JsonResponse
{
$command = new PlaceOrderCommand(
customerId: new CustomerId($request->user()->id),
items: array_map(
fn ($row) => new CartItem(new ProductId($row['product_id']), (int) $row['qty']),
$request->validated('items'),
),
);
try {
$orderId = $this->placeOrder->handle($command);
} catch (OutOfStock | PaymentDeclined | ProductNotFound $e) {
return response()->json(['error' => $e->getMessage()], 422);
}
return response()->json(['order_id' => (string) $orderId], 201);
}
}
A repository implements the port using Eloquent, mapping between the domain Order and the orders table. The Eloquent model is an implementation detail that never leaves this file.
// adapters/persistence/EloquentOrderRepository.php
final class EloquentOrderRepository implements OrderRepository
{
public function save(Order $order): void
{
DB::transaction(function () use ($order) {
$row = OrderModel::updateOrCreate(
['id' => (string) $order->id],
['customer_id' => (string) $order->customerId, 'status' => $order->statusValue()],
);
$row->lines()->delete();
foreach ($order->lines as $line) {
$row->lines()->create([
'product_id' => (string) $line->productId,
'unit_price' => $line->unitPrice->cents(),
'quantity' => $line->quantity,
]);
}
});
}
}
A gateway adapts the Stripe SDK to the PaymentGateway port:
// adapters/payment/StripePaymentGateway.php
final class StripePaymentGateway implements PaymentGateway
{
public function __construct(private StripeClient $stripe) {}
public function charge(CustomerId $customer, Money $amount): PaymentResult
{
try {
$intent = $this->stripe->paymentIntents->create([
'amount' => $amount->cents(),
'currency' => strtolower($amount->currency()),
'customer' => (string) $customer,
'confirm' => true,
]);
return PaymentResult::ok($intent->id);
} catch (CardException $e) {
return PaymentResult::failed($e->getMessage());
}
}
}
5. Frameworks & Drivers — wire it together
The only place the layers meet is the composition root. In Laravel that is a service provider:
// app/Providers/OrderingServiceProvider.php
public function register(): void
{
$this->app->bind(ProductCatalog::class, EloquentProductCatalog::class);
$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
$this->app->bind(OrderNotifier::class, MailOrderNotifier::class);
$this->app->bind(PaymentGateway::class, function ($app) {
return new StripePaymentGateway($app->make(StripeClient::class));
});
}
Laravel now knows how to build PlaceOrder: it sees the four interface type-hints, resolves each to the concrete class above, and injects them. The controller asks for PlaceOrder, gets a fully wired instance, and none of the inner code ever named a framework class.
What this buys you
A test for the use case needs no framework:
public function test_it_declines_when_the_card_fails(): void
{
$placeOrder = new PlaceOrder(
catalog: new InMemoryCatalog([$this->product('p1', price: 1000, stock: 5)]),
orders: $orders = new InMemoryOrderRepository(),
payments: new AlwaysDeclinesGateway(),
notifier: new NullNotifier(),
);
$this->expectException(PaymentDeclined::class);
$placeOrder->handle(new PlaceOrderCommand(
new CustomerId('c1'),
[new CartItem(new ProductId('p1'), 2)],
));
$this->assertCount(0, $orders->all()); // nothing persisted
}
The traps
| Mistake | Why it hurts |
|---|---|
Entities that extend Model |
Eloquent is now a core dependency. Every test needs a database; the domain is coupled to the ORM’s lifecycle. |
Use cases that take a Request or return a JsonResponse |
The application layer now depends on HTTP. It can’t be reused from a queue job, a command, or a test without faking the web. |
| Interfaces in the outer layer | If OrderRepository lives next to Eloquent instead of next to the use case, the arrow points the wrong way. The port belongs to the consumer. |
A Services/ folder that just wraps the ORM |
Layers named but not respected. If OrderService calls DB:: and returns arrays, you have indirection without inversion. |
| Applying all four layers to a CRUD admin panel | Clean Architecture pays off where business rules are rich and long-lived. For a settings table, a controller and a model are fine. |
When to reach for it
Clean Architecture is an investment: more files, more interfaces, more indirection. It earns that cost when:
- the domain rules are non-trivial and will outlive the current framework version;
- you have multiple entry points to the same logic (HTTP, queue, CLI, scheduled job);
- testing speed matters and you don’t want a database in every test;
- the team is large enough that clear boundaries prevent the codebase turning to mud.
For a weekend CRUD app, skip it. For the payments module of a platform you will run for five years, the boundaries are worth every extra file.
A five-point checklist
- Point every source dependency inward. If an inner class names an outer class, you have a violation.
- Keep entities and use cases free of framework types. No
Model, noRequest, no facades, no SDK classes. - Let the consumer own the interface. The port sits with the use case that needs it; the outer layer implements it.
- Make the use case the unit of the application layer — one class per action, readable as a business process.
- Wire everything in one composition root (a service provider) and nowhere else.
Conclusion
Clean Architecture is one rule wearing four circles: details depend on rules, never the reverse. You enforce it by having each inner layer declare the interfaces it needs and letting the framework layer implement them at a single wiring point.
The payoff is that the code most expensive to get wrong — the business logic — is the code least entangled with the parts you will replace. Frameworks come and go; the meaning of “place an order” stays put.