Implementing the Repository Pattern in PHP Without a Framework
AI generated
<?php
8.4
PHP · Database · Design Patterns
Implementing the Repository Pattern Without a Framework
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.

18 min read Interface · PDO · in memory test double PHP 8.4 · framework independent

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.

11. FAQ: Repository Pattern in PHP

1What is the repository pattern in simple terms?
A pattern that hides data access behind a collection metaphor, so a repository behaves like an in memory collection.
2Difference from Active Record?
The repository pattern keeps persistence out of the domain object, a separate repository handles it. Active Record carries save()/delete() on the object itself.
3Why is the interface so important?
Business logic depends only on the interface, allowing swapping storage technology and testing with test doubles.
4How do I test services using a repository?
With an in memory implementation of the interface that behaves identically but needs no database connection.
5What is hydration?
The conversion of database rows into typed domain objects inside the repository implementation.
6How do you handle dynamic filters?
With the specification pattern, encapsulating filter conditions passed to a generic matching() method.
7Do you need a DI container?
Not necessarily, but a container makes switching between implementations considerably easier.
8How do transactions work across multiple repositories?
Through a unit of work or a centrally controlled PDO transaction sharing the same connection.
9Is it sensible for every project?
Not necessarily, for simple CRUD applications Active Record with less boilerplate can outweigh it.
10Can a repository use a query builder?
Yes, that is common and remains a pure implementation detail within the repository.