A controller should be thin: read the request, call something, return a response. If the controller also checks stock, calculates discounts, saves orders and sends emails, it becomes a long, tangled method that nobody wants to change.
A service is a class that holds a piece of business logic (“what the application does”). Controllers call services. Services use repositories and other services.
Controller -> Service (business rules) -> Repository (data)
A fat controller (the problem)
<?php
// Everything in one place: hard to read, test and reuse
$product = $pdo->query("SELECT * FROM products WHERE id = " . (int) $_POST["id"])->fetch();
if ($product["stock"] < $_POST["qty"]) { /* ... */ }
$total = $product["price"] * $_POST["qty"];
if ($total > 1000) { $total *= 0.9; }
$pdo->exec("INSERT INTO orders ...");
$pdo->exec("UPDATE products SET stock = stock - ...");
mail($user["email"], "Order", "...");
The same with a service
<?php
final class OutOfStockException extends RuntimeException {}
interface ProductStore
{
public function find(int $id): ?array;
public function reduceStock(int $id, int $qty): void;
}
interface OrderStore
{
public function save(int $productId, int $qty, float $total): int;
}
final class OrderService
{
public function __construct(
private ProductStore $products,
private OrderStore $orders,
) {}
public function place(int $productId, int $qty): int
{
if ($qty < 1) {
throw new InvalidArgumentException("Quantity must be at least 1.");
}
$product = $this->products->find($productId)
?? throw new InvalidArgumentException("Unknown product.");
if ($product["stock"] < $qty) {
throw new OutOfStockException("Only {$product['stock']} left.");
}
$total = $this->total($product["price"], $qty);
$this->products->reduceStock($productId, $qty);
return $this->orders->save($productId, $qty, $total);
}
public function total(float $price, int $qty): float
{
$total = $price * $qty;
return $total > 1000 ? round($total * 0.9, 2) : $total; // 10% off big orders
}
}
// Simple in-memory fakes, to test the rules without a database
$products = new class implements ProductStore {
public array $rows = [1 => ["price" => 600.0, "stock" => 3]];
public function find(int $id): ?array { return $this->rows[$id] ?? null; }
public function reduceStock(int $id, int $qty): void { $this->rows[$id]["stock"] -= $qty; }
};
$orders = new class implements OrderStore {
public array $saved = [];
public function save(int $productId, int $qty, float $total): int
{
$this->saved[] = compact("productId", "qty", "total");
return count($this->saved);
}
};
$service = new OrderService($products, $orders);
echo "Order #" . $service->place(1, 2) . " total " . $orders->saved[0]["total"] . "\n";
echo "Stock left: " . $products->rows[1]["stock"] . "\n";
try {
$service->place(1, 5);
} catch (OutOfStockException $e) {
echo $e->getMessage();
}
Order #1 total 1080
Stock left: 1
Only 1 left.
The rules (a quantity must be positive, stock must be enough, big orders get a discount) live in one class, and we could test them in a few lines without a database, by passing fake stores.
What a service is
- A class with verbs:
place(),cancel(),register(),resetPassword(). - It receives plain values, not
$_POST. - It throws exceptions for rule violations. The controller turns them into responses.
- It gets its dependencies through the constructor.
- It does not know about HTTP, HTML or
header().
The controller after the change
<?php
public function store(): array
{
try {
$orderId = $this->orders->place((int) $_POST["product_id"], (int) $_POST["qty"]);
} catch (OutOfStockException $e) {
return ["status" => 409, "view" => "order-form", "data" => ["error" => $e->getMessage()]];
} catch (InvalidArgumentException $e) {
return ["status" => 422, "view" => "order-form", "data" => ["error" => $e->getMessage()]];
}
return ["status" => 303, "redirect" => "/orders/" . $orderId];
}
When you need a service
- A rule is used by more than one controller (web page, API, command-line job).
- A task touches several repositories or external systems.
- A controller method grows beyond about 15 lines.
When you do not
A simple “list all products” page does not need a service. Do not create empty layers for ceremony.
Transactions
A service method that changes several tables should wrap the work in a database transaction, so everything happens or nothing does (see Handling Database Errors).