Hiding data access behind an interface
The repository pattern separates domain logic from the concrete data access technology by hiding a collection of domain objects behind a simple interface. Understanding the repository pattern in plain PHP lets you test business logic without touching a real database, and swap the underlying storage technology later without touching the rest of the code.
Table of contents
- 1. What problem the repository pattern solves
- 2. The repository interface: a contract, not an implementation
- 3. A concrete PDO implementation of the repository
- 4. Returning domain objects instead of arrays
- 5. Specifications and filters beyond findById
- 6. An in memory repository as a test double
- 7. Working together with unit of work and transactions
- 8. Wiring repositories through dependency injection
- 9. Repository pattern compared to Active Record
- 10. Summary
- 11. FAQ
1. What problem the repository pattern solves
The repository pattern is a design pattern that hides data access behind a collection metaphor: from the point of view of business logic, a repository behaves like an in memory collection of objects, even though SQL queries run against a database behind the scenes. Without the repository pattern, SQL code typically spreads across the entire application, into controllers, services and even templates, which turns every change to data access logic into a search across dozens of files.
The central benefit of the repository pattern is the separation of domain logic and persistence. A service that cancels an order does not need to know whether the order was loaded from MySQL, a REST API or a file. It simply calls $orderRepository->findById($id) and works with the returned domain object. This decoupling makes business logic testable independent of the concrete storage technology.
An important distinction from the query builder: a repository works with domain objects and business method names like findActiveCustomers(), while a query builder stays at the level of SQL constructs like SELECT and WHERE. A repository often uses a query builder or raw PDO internally, but hides these details completely from the caller.
2. The repository interface: a contract, not an implementation
The starting point of every clean repository pattern is an interface that defines exclusively business methods, without any hint of the concrete storage technology. Domain logic depends only on this interface, never on a concrete implementation. This matches the dependency inversion principle from SOLID: high level code depends on an abstraction, not on low level details like PDO or a particular SQL dialect.
This separation pays off above all during testing. A service programmed against the repository interface can be equipped with a test double of the interface in unit tests, entirely without a database connection. The repository pattern is what makes this kind of test practical in the first place, because domain logic never references PDO or a SQL library directly anywhere.
<?php
declare(strict_types=1);
interface CustomerRepositoryInterface
{
public function findById(int $id): ?Customer;
/** @return Customer[] */
public function findActive(): array;
public function save(Customer $customer): void;
public function delete(int $id): void;
}
final class Customer
{
public function __construct(
public readonly ?int $id,
public readonly string $name,
public readonly string $email,
public readonly bool $active,
) {
}
}
3. A concrete PDO implementation of the repository
The production implementation of the repository pattern translates every interface method into concrete SQL queries through PDO and turns the raw database rows into domain objects. This conversion, often called hydration, is a central part of every repository implementation, because it prevents associative arrays with database columns from leaking into business logic.
Conversion errors, such as forgotten type casts for boolean columns, belong exclusively in the repository implementation, never in the calling code. The repository pattern thus draws a clear boundary: everything related to the database stays inside the implementation, everything related to business rules stays outside.
<?php
declare(strict_types=1);
final class PdoCustomerRepository implements CustomerRepositoryInterface
{
public function __construct(private readonly PDO $pdo)
{
}
public function findById(int $id): ?Customer
{
$statement = $this->pdo->prepare(
'SELECT id, name, email, active FROM customers WHERE id = ?'
);
$statement->execute([$id]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
return $row === false ? null : $this->hydrate($row);
}
public function findActive(): array
{
$statement = $this->pdo->query(
'SELECT id, name, email, active FROM customers WHERE active = 1'
);
return array_map($this->hydrate(...), $statement->fetchAll(PDO::FETCH_ASSOC));
}
public function save(Customer $customer): void
{
if ($customer->id === null) {
$statement = $this->pdo->prepare(
'INSERT INTO customers (name, email, active) VALUES (?, ?, ?)'
);
$statement->execute([$customer->name, $customer->email, (int) $customer->active]);
return;
}
$statement = $this->pdo->prepare(
'UPDATE customers SET name = ?, email = ?, active = ? WHERE id = ?'
);
$statement->execute([$customer->name, $customer->email, (int) $customer->active, $customer->id]);
}
public function delete(int $id): void
{
$statement = $this->pdo->prepare('DELETE FROM customers WHERE id = ?');
$statement->execute([$id]);
}
/** @param array<string, mixed> $row */
private function hydrate(array $row): Customer
{
return new Customer(
id: (int) $row['id'],
name: (string) $row['name'],
email: (string) $row['email'],
active: (bool) $row['active'],
);
}
}
4. Returning domain objects instead of arrays
A common mistake when retrofitting the repository pattern is to keep returning associative arrays instead of real domain objects. That does remove SQL from business logic, but type safety falls by the wayside: a typo in an array key only becomes visible at runtime, not already during static analysis with PHPStan. Real domain objects with typed, sometimes readonly properties make such mistakes visible already when the code is checked.
Domain objects within a repository pattern should also not contain any persistence logic themselves. A Customer class knows its own business rules, such as a deactivate() method, but it knows nothing about SQL or the table it was loaded from. This strict separation fundamentally distinguishes the repository pattern from the Active Record approach, where the domain object saves itself.
5. Specifications and filters beyond findById
Real applications often need more than simple methods like findById() or findActive(). For more complex, dynamic search queries, the repository pattern is often combined with the specification pattern: a specification class encapsulates a single filter condition, and the repository accepts one or more specifications to assemble a WHERE clause from them.
This combination prevents the repository interface from becoming overloaded with dozens of specialized findByNameAndActiveAndRegion() methods. Instead, a generic matching(Specification $spec) method stays part of the repository pattern, internally feeding a query builder with the conditions from the specification.
<?php
declare(strict_types=1);
interface CustomerSpecification
{
public function toSqlCondition(): string;
/** @return array<int, mixed> */
public function getBindings(): array;
}
final class ActiveInRegion implements CustomerSpecification
{
public function __construct(private readonly string $region)
{
}
public function toSqlCondition(): string
{
return 'active = 1 AND region = ?';
}
public function getBindings(): array
{
return [$this->region];
}
}
// Extended repository method
public function matching(CustomerSpecification $spec): array
{
$sql = 'SELECT id, name, email, active FROM customers WHERE ' . $spec->toSqlCondition();
$statement = $this->pdo->prepare($sql);
$statement->execute($spec->getBindings());
return array_map($this->hydrate(...), $statement->fetchAll(PDO::FETCH_ASSOC));
}
6. An in memory repository as a test double
The biggest practical benefit of the repository pattern shows up when testing business logic. An in memory implementation of the repository interface, which merely holds objects in an array, behaves identically to the PDO implementation from the point of view of business logic, but runs without a database connection and is therefore considerably faster. Unit tests for services that depend on a repository can inject this in memory variant and control exactly which data is returned.
This testability is the actual business reason to introduce the repository pattern in the first place. Without this pattern, tests would either have to run against a real test database, which slows test runs down considerably, or resort to complex mocking frameworks for PDO calls, which makes the tests unnecessarily fragile.
<?php
declare(strict_types=1);
final class InMemoryCustomerRepository implements CustomerRepositoryInterface
{
/** @var array<int, Customer> */
private array $customers = [];
private int $nextId = 1;
public function findById(int $id): ?Customer
{
return $this->customers[$id] ?? null;
}
public function findActive(): array
{
return array_values(array_filter($this->customers, fn (Customer $c) => $c->active));
}
public function save(Customer $customer): void
{
$id = $customer->id ?? $this->nextId++;
$this->customers[$id] = new Customer($id, $customer->name, $customer->email, $customer->active);
}
public function delete(int $id): void
{
unset($this->customers[$id]);
}
}
7. Working together with unit of work and transactions
As soon as several repositories collaborate within a single business operation, for example creating an order together with its associated invoice lines, an overarching transaction boundary becomes necessary. The repository pattern alone does not solve this problem, since each repository persists independently. It is therefore often combined with a unit of work instance that wraps a PDO transaction and lets all involved repositories commit or roll back at the right moment.
In a lean custom implementation, it is often enough to pass the same PDO instance to several repositories and keep transaction control at a central place in the application code, instead of pushing it into every single repository. This keeps the repository pattern focused on its actual job: encapsulating access to a collection of domain objects, not managing transaction logic.
8. Wiring repositories through dependency injection
For services to actually program against the interface instead of a concrete implementation, a DI container needs to bind the interface to the matching implementation. In frameworks like Symfony this happens through service configuration, in a framework independent application a simple container suffices, one that maintains a mapping from interface to concrete class and automatically injects the correct repository implementation when resolving.
For tests, the same binding is simply switched: instead of PdoCustomerRepository, InMemoryCustomerRepository is registered, without the service code itself being touched. This interchangeability is the practical core of the repository pattern and shows why the extra layer of abstraction over direct PDO access pays off in most medium to large projects.
9. Repository pattern compared to Active Record
The alternative to the repository pattern is the Active Record approach, where a domain object itself carries methods like save() or delete() and is thus directly responsible for its own persistence. Frameworks like Laravel with Eloquent deliberately rely on Active Record, because it requires less boilerplate code for many CRUD heavy applications.
| Criterion | Repository pattern | Active Record |
|---|---|---|
| Testability without a database | Very good, via test double | Difficult, object carries persistence |
| Boilerplate code | More due to interface and implementation | Less, directly on the object |
| Separation of concerns | Clearly separated | Object knows itself and the database |
| Swapping storage technology | Only swap the implementation | Tightly coupled to the ORM |
| Best fit for | Complex domain logic, DDD approaches | Fast, CRUD heavy applications |
For projects with complex business logic and the ambition to test that logic independent of the database, the repository pattern is the considerably more robust choice. For simple CRUD applications with little domain logic, the lower boilerplate overhead of Active Record can outweigh it.
10. Summary
The repository pattern encapsulates data access behind a business focused interface and consistently separates domain logic from the concrete storage technology. The most important building blocks are an interface with exclusively business method names, a concrete PDO implementation with clean hydration into domain objects, and an in memory variant as a fast test double for unit tests.
Extensions such as the specification pattern for dynamic filters and a unit of work for cross cutting transactions sensibly complement the repository pattern in larger applications. Compared to Active Record, the repository pattern requires somewhat more code, but pays off clearly in projects with complex, testable business logic through its clear separation of concerns.
Repository Pattern Without a Framework — The essentials at a glance
Interface first
Domain logic depends only on the interface, never on PDO or SQL directly.
Hydration
Database rows are converted into typed domain objects, never passed through as arrays.
In memory test double
Fast unit tests for business logic, completely without a database connection.
Distinction
Not Active Record: domain objects know nothing about persistence logic.