- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: System Design Pocket Guide: Fundamentals
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Open the app/Services/ directory of a four-year-old Laravel codebase. You will find them. OrderManager. UserManager. PaymentManager. NotificationManager. Each one is a 900-line class with a private constructor, fifteen public methods, six injected dependencies, and a test file that mocks four of them per test case.
Ask the engineer who wrote OrderManager what it does. The answer is always the same shape: "It manages orders." That's not a definition. It's a tautology with the verb sanded off.
The word Manager is a tell. It means the author needed a place to put code and could not name what that code does. So they reached for the most generic noun in the English language and stapled it to the domain object. The class then becomes a magnet. Every new behavior that touches an order ends up inside OrderManager, because where else would it go?
This post is about what Manager is actually hiding, and the three concrete shapes you should be using instead.
The class that was every class
Here is a stripped-down OrderManager you will recognise, in shape, from roughly every PHP codebase you have worked in. The names change. The structure does not.
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Order;
use App\Models\Customer;
use App\Mail\OrderConfirmation;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
final class OrderManager
{
public function createOrder(int $customerId, array $items): Order
{
$customer = Customer::findOrFail($customerId);
$subtotal = 0;
foreach ($items as $item) {
$subtotal += $item['price_cents'] * $item['quantity'];
}
$discount = 0;
if ($customer->tier === 'gold') {
$discount = (int) round($subtotal * 0.10);
} elseif ($subtotal > 50_000) {
$discount = (int) round($subtotal * 0.05);
}
$total = $subtotal - $discount;
return DB::transaction(function () use ($customer, $items, $total) {
$order = Order::create([
'customer_id' => $customer->id,
'total_cents' => $total,
'status' => 'pending',
]);
$order->items()->createMany($items);
Mail::to($customer->email)->send(new OrderConfirmation($order));
Log::info('order.created', ['id' => $order->id]);
return $order;
});
}
public function findOrder(int $id): ?Order
{
return Order::with('items')->find($id);
}
public function findOrdersForCustomer(int $customerId): iterable
{
return Order::where('customer_id', $customerId)
->orderByDesc('created_at')
->get();
}
public function cancelOrder(int $id, string $reason): void
{
$order = Order::findOrFail($id);
$order->status = 'cancelled';
$order->cancellation_reason = $reason;
$order->save();
Mail::to($order->customer->email)
->send(new OrderConfirmation($order));
}
public function recalculateTotal(Order $order): int
{
$subtotal = 0;
foreach ($order->items as $item) {
$subtotal += $item->price_cents * $item->quantity;
}
$customer = $order->customer;
$discount = 0;
if ($customer->tier === 'gold') {
$discount = (int) round($subtotal * 0.10);
} elseif ($subtotal > 50_000) {
$discount = (int) round($subtotal * 0.05);
}
return $subtotal - $discount;
}
}
Six things in one class. Read them again:
- Compute a subtotal from line items.
- Apply a discount rule based on customer tier and basket size.
- Persist the order and its items inside a transaction.
- Send a confirmation email.
- Look up orders by id or customer.
- Cancel an order and notify the customer.
The reason this class fights every test you write is not Laravel. The class is answering three different questions at once: what should happen when a customer places an order, how do orders live in the database, and what is the pricing rule. Those are three jobs. They live in three different parts of any architecture worth the name. Manager is the bag they got dumped into.
The three shapes underneath
Every Manager class you will meet in a PHP codebase decomposes into the same three roles. Once you name them, the code rewrites itself.
Shape 1 — The Use Case
A use case is one verb. PlaceOrder. CancelOrder. ChangeShippingAddress. It is the thing the application does when a user, a cron job, or a webhook asks for it. One use case = one entry point. It orchestrates everything else.
The signature is the contract: a request DTO in, a result out (or a domain exception). It owns the transaction boundary. It does not know how the database works, only that something persists the result.
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Domain\Order\Order;
use App\Domain\Order\OrderRepository;
use App\Domain\Order\Pricing;
use App\Domain\Customer\CustomerRepository;
use App\Application\Order\Dto\PlaceOrderRequest;
final readonly class PlaceOrderUseCase
{
public function __construct(
private CustomerRepository $customers,
private OrderRepository $orders,
private Pricing $pricing,
private OrderConfirmationNotifier $notifier,
) {}
public function execute(PlaceOrderRequest $request): Order
{
$customer = $this->customers->byId($request->customerId);
$total = $this->pricing->totalFor(
items: $request->items,
customerTier: $customer->tier,
);
$order = Order::placeFor(
customer: $customer,
items: $request->items,
totalCents: $total,
);
$this->orders->save($order);
$this->notifier->confirm($order);
return $order;
}
}
The whole class is sixteen lines. It reads top to bottom in business language: find the customer, price the basket, build the order, save it, notify them. No SQL. No Mail::to(...). No facades.
Notice what the use case is not. It is not a "service." It is not a "manager." One verb, that's it. If a second verb shows up, like ChangeShippingAddress, that is a second class. A second file. The two never share state, because they don't need to.
Shape 2 — The Repository
A repository is the port for persistence. It speaks domain language in and out. byId(OrderId $id): ?Order. save(Order $order): void. placedAfter(DateTimeImmutable $cutoff): iterable. It does not return rows, query builders, or Eloquent models. It returns domain objects.
The Eloquent model is a database concern. It lives behind the repository, as an implementation detail, not on the contract.
<?php
declare(strict_types=1);
namespace App\Domain\Order;
interface OrderRepository
{
public function byId(OrderId $id): ?Order;
public function save(Order $order): void;
/** @return iterable<Order> */
public function placedFor(CustomerId $customerId): iterable;
}
And the Eloquent implementation:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Order;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepository;
use App\Domain\Customer\CustomerId;
use App\Models\OrderEloquent;
final class EloquentOrderRepository implements OrderRepository
{
public function byId(OrderId $id): ?Order
{
$row = OrderEloquent::with('items')->find($id->value);
return $row === null ? null : $this->hydrate($row);
}
public function save(Order $order): void
{
OrderEloquent::query()->updateOrCreate(
['id' => $order->id()->value],
[
'customer_id' => $order->customerId()->value,
'total_cents' => $order->totalCents(),
'status' => $order->status()->value,
],
);
}
public function placedFor(CustomerId $customerId): iterable
{
return OrderEloquent::where('customer_id', $customerId->value)
->orderByDesc('created_at')
->get()
->map(fn ($row) => $this->hydrate($row));
}
private function hydrate(OrderEloquent $row): Order
{
$items = $row->items->map(fn ($item) => new LineItem(
sku: $item->sku,
priceCents: (int) $item->price_cents,
quantity: (int) $item->quantity,
))->all();
return Order::rehydrate(
id: new OrderId($row->id),
customerId: new CustomerId($row->customer_id),
items: $items,
totalCents: (int) $row->total_cents,
status: OrderStatus::from($row->status),
);
}
}
Two classes. One contract that the domain talks to. One implementation that knows about Eloquent. Tomorrow you swap PostgreSQL for DynamoDB or move off Eloquent entirely: the use case does not change a single line.
This is the rule a Manager class breaks the hardest. OrderManager calls Order::create([...]) directly, so the application layer is now welded to Eloquent forever. Refactoring the storage layer means changing the application layer. That is the coupling tax compounding by the year.
Shape 3 — The Domain Service
A domain service is the third shape, and the one that often gets missed. It exists for behavior that belongs to the domain but doesn't naturally sit on a single entity. Pricing is the textbook example. The discount depends on the customer's tier and the basket subtotal. That rule does not belong to the customer alone, and it does not belong to a line item either. It is its own concept.
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use App\Domain\Customer\CustomerTier;
final readonly class Pricing
{
/** @param list<LineItem> $items */
public function totalFor(array $items, CustomerTier $customerTier): int
{
$subtotal = 0;
foreach ($items as $item) {
$subtotal += $item->priceCents * $item->quantity;
}
$discount = match (true) {
$customerTier === CustomerTier::Gold => (int) round($subtotal * 0.10),
$subtotal > 50_000 => (int) round($subtotal * 0.05),
default => 0,
};
return $subtotal - $discount;
}
}
No database. No framework. No I/O. Pure PHP. You can write a PricingTest that runs in two milliseconds and covers every discount branch with no setUp() ceremony.
A domain service is not the same as the dumping-ground service class the PHP world has used for fifteen years. The difference: a domain service is named for what it does (Pricing, ShippingQuote, InventoryAllocation), it lives in the domain layer, and it has no dependencies on the framework or the database. If your "service" injects a repository, an Eloquent model, and a mailer, it is not a domain service. It is a use case wearing the wrong hat.
How to know which shape you have
When you sit in front of a Manager method and need to split it, ask one question per method:
- Does it orchestrate? Multiple steps, transaction boundary, talks to repositories and notifiers, ends with "and then something gets persisted and something gets told." → Use case.
- Does it persist or fetch? Reads or writes rows; the return type is a domain object or a collection of them. → Repository method.
- Is it a rule? Pure computation on domain values, no I/O, deterministic. → Domain service (or a method on the entity itself, if the rule belongs to one entity).
Run the question over the six things OrderManager was doing:
- Subtotal computation — rule. →
Pricing::totalFor(). - Discount logic — rule. →
Pricing::totalFor(). - Persist the order — persistence. →
OrderRepository::save(). - Send confirmation — orchestration step. → use case calls
OrderConfirmationNotifier. - Lookup by id / customer — persistence. →
OrderRepository::byId(),OrderRepository::placedFor(). - Cancel an order — orchestration. →
CancelOrderUseCase, separate file.
OrderManager is now four classes, each with one job, each named for the job. The 900-line god class is gone. The tests stopped mocking four things per case. New behavior has an obvious home before you write it.
Why naming carries the architecture
Naming is not a cosmetic concern. A class called OrderManager invites every developer who touches the codebase to throw another method into it, because the name is generic enough to absorb anything. A class called PlaceOrderUseCase does not. The minute someone tries to add cancelOrder() to it, the name itself screams.
That is the actual payoff of clean architecture in PHP, and it has nothing to do with frameworks. The framework is downstream of how you name things. The names tell future-you, and future-your-team, where new code goes — and where it does not.
If your codebase has more than two classes named *Manager, you have not picked an architecture. You have picked the absence of one.
If this was useful
Decoupled PHP walks the full layout from a single use case up to a multi-adapter production service — HTTP, queue workers, Doctrine, message brokers, all sitting behind ports the domain owns. It is the long-form version of the rule above: when Laravel or Symfony becomes an adapter instead of the application, the Manager problem stops happening on its own.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)