Skip to content

PHP Tutorial Practical PHP Development

Service Layer Concept

Lesson 322 of 369 2 min read By Deepak Paswan Updated Oct 5, 2026

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.

Text
PLAIN
Controller  ->  Service (business rules)  ->  Repository (data)

A fat controller (the problem)

index.php
PHP
<?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

index.php
PHP
<?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();
}
Output
OUTPUT
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

index.php
PHP
<?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).

lightbulb
Test the service, not the page
Services are where the important rules are. A few small tests on them protect you more than clicking through pages.