The Repository Pattern in PHP: A Practical Before/After Guide
The repository pattern is a design pattern that isolates data-access logic behind a dedicated class, exposing intention-revealing methods like findActiveUsers() instead of scattering raw queries or ORM calls across controllers and services. Its main benefit is decoupling: the rest of the application doesn't need to know whether data comes from a SQL database, an API, or a cache — and code that depends on a repository interface is dramatically easier to test than code that queries a database directly. This article shows what that looks like in practice, with a real before/after refactor. It follows on from our overview of the core components of a modern PHP framework, where repositories were introduced as one of the structural pillars — this piece goes deeper into that one component specifically.
The Problem: A Fat Controller
Here is a common pattern in PHP codebases that grew organically without a repository layer:
```php
class OrderController
{
public function cancel(int $orderId): Response
{
$order = DB::table('orders')
->where('id', $orderId)
->where('status', '!=', 'cancelled')
->first();
if (!$order) {
return new Response('Order not found', 404);
}
DB::table('orders')
->where('id', $orderId)
->update(['status' => 'cancelled', 'cancelled_at' => now()]);
DB::table('inventory')
->where('order_id', $orderId)
->update(['reserved' => 0]);
return new Response('Order cancelled', 200);
}
}
```
This works, but it has three concrete problems. First, the controller now knows implementation details of the database schema — table names, column names, query structure — which is not its job. Second, this exact query logic likely gets duplicated the next time another part of the application needs to cancel an order. Third, and most importantly for long-term reliability: testing this method requires a real (or in-memory) database connection, because the query logic is inline. There is no way to substitute a fake data source.
The Fix: Repository + Service Layer
Splitting this into a repository (data access) and a service (business logic) separates two concerns that were previously tangled together:
```php
interface OrderRepositoryInterface
{
public function findCancellable(int $orderId): ?Order;
public function markCancelled(Order $order): void;
}
class OrderRepository implements OrderRepositoryInterface
{
public function findCancellable(int $orderId): ?Order
{
return Order::where('id', $orderId)
->where('status', '!=', 'cancelled')
->first();
}
public function markCancelled(Order $order): void
{
$order->update(['status' => 'cancelled', 'cancelled_at' => now()]);
}
}
class OrderCancellationService
{
public function __construct(
private OrderRepositoryInterface $orders,
private InventoryRepositoryInterface $inventory
) {}
public function cancel(int $orderId): bool
{
$order = $this->orders->findCancellable($orderId);
if (!$order) {
return false;
}
$this->orders->markCancelled($order);
$this->inventory->releaseReservation($orderId);
return true;
}
}
class OrderController
{
public function __construct(private OrderCancellationService $service) {}
public function cancel(int $orderId): Response
{
$cancelled = $this->service->cancel($orderId);
return $cancelled
? new Response('Order cancelled', 200)
: new Response('Order not found', 404);
}
}
```
Three things changed, each solving one of the three problems above. The controller no longer knows anything about tables or columns — it only calls a service method. The cancellation logic (find, update order, release inventory) is now in one reusable place instead of being duplicated wherever an order needs cancelling. And critically, OrderCancellationService depends on OrderRepositoryInterface, an interface, not a concrete class — which means a test can inject a fake in-memory implementation instead of hitting a real database.
Why This Matters for Testing Specifically
With the interface in place, testing the cancellation logic looks like this:
```php
class FakeOrderRepository implements OrderRepositoryInterface
{
private array $orders = [];
public function seed(Order $order): void
{
$this->orders[$order->id] = $order;
}
public function findCancellable(int $orderId): ?Order
{
$order = $this->orders[$orderId] ?? null;
return $order && $order->status !== 'cancelled' ? $order : null;
}
public function markCancelled(Order $order): void
{
$order->status = 'cancelled';
}
}
// In the test:
$repository = new FakeOrderRepository();
$repository->seed(new Order(id: 1, status: 'pending'));
$service = new OrderCancellationService($repository, new FakeInventoryRepository());
$result = $service->cancel(1);
assert($result === true);
```
No database connection, no test fixtures to clean up between runs, no risk of tests interfering with each other over shared state. This is a direct, practical consequence of the repository pattern rather than an abstract benefit: the interface is what makes fast, isolated tests possible in the first place.
When Is a Repository Layer Overkill?
For very small scripts or projects where the data layer will never realistically be swapped or need dedicated testing, a full repository layer can be unnecessary ceremony. The pattern earns its cost on applications expected to grow, be maintained by more than one person, or be tested thoroughly and continuously — which describes most production software beyond a prototype.
Frequently Asked Questions
Is the repository pattern the same as the ORM?
No. An ORM (like Eloquent or Doctrine) maps database rows to objects. A repository sits on top of that, wrapping ORM calls (or raw queries) behind an interface with business-relevant method names. You can use an ORM without a repository layer, but combining both gives you the ORM's convenience with the repository's testability and decoupling.
Does Every Method Need an Interface?
Every public method defined on an interface should be exercised by a Scenario in your tests — this is a direct consequence of following Domain-Driven Design properly. In BDD terms, a Scenario describes one concrete way the application can be used from a domain perspective — for example, "Scenario: validation fails when the email is missing" and "Scenario: validation succeeds with valid input" — written in plain domain language rather than code. In practice, this means the repository gets mocked in your test suite, so every Scenario can exercise the full application logic without ever touching real data.
If an interface method has no Scenario covering it, one of two things is true. Either the method is unused speculative capability that should be removed rather than left to rot. Or, if the method genuinely is used by the application, the missing Scenario means you have a real gap in your domain coverage that needs closing before that code can be trusted. Either way, an interface method with no Scenario behind it is a signal to act on, not a detail to ignore.
Does this add too much boilerplate for a small team?
It adds files, not complexity — each file has one clear responsibility. In practice, the time saved on debugging tangled controller logic and writing reliable tests tends to outweigh the extra file count fairly quickly, even on small teams.
Have a PHP codebase that needs a cleaner architecture, or a new project that needs one built right from the start? Get in touch.