Design Patterns And Data Architecture
Repository Pattern
A repository gives application code a collection-like way to load and persist important objects or read models without spreading SQL, ORM query details, or storage-specific APIs through controllers and use cases. In PHP applications, repositories often sit between application services and persistence tools such as PDO, Doctrine ORM, Laravel Eloquent, external APIs, or document databases.
The repository pattern is useful because storage details are rarely neutral. SQL shape, eager loading, transaction boundaries, query performance, tenant scoping, deleted-record rules, and mapping decisions can affect business behavior. If those details are copied into controllers and jobs, the same application question may be answered differently in different places. A repository gives that question a named boundary.
A repository is not just a class with CRUD methods. find(), all(), save(), and delete() can be useful, but they are not the pattern by themselves. The better test is whether the repository expresses application queries and persistence operations in language the application understands.
What A Repository Owns
A repository owns access to a set of related application objects or read models. It can hide how data is fetched, mapped, scoped, filtered, and saved. It should not own every business rule. A cancellation policy belongs in an order, policy, domain service, or application service. The repository can load the order needed to apply that rule.
Good repository methods are named after application needs:
findActiveUserByEmail()findOrderForCancellation()saveInvoice()nextPendingWebhookDelivery()ordersReadyForFulfilment()
Weak repository methods often expose storage mechanics without adding meaning:
where(array $conditions)query()getByColumn(string $column, mixed $value)executeRaw(string $sql)
Those generic methods may belong in lower-level database utilities, but they do not protect application logic from persistence details.
A Small Repository Interface
This example uses an in-memory implementation so the pattern is visible without a database. The interface is named around the application concept, and the method answers a useful question.
<?php
declare(strict_types=1);
final readonly class User
{
public function __construct(
public int $id,
public string $email,
public bool $active,
) {
}
}
interface UserRepository
{
public function findActiveByEmail(string $email): ?User;
}
final class InMemoryUserRepository implements UserRepository
{
/** @var list<User> */
private array $users;
public function __construct(User ...$users)
{
$this->users = $users;
}
public function findActiveByEmail(string $email): ?User
{
foreach ($this->users as $user) {
if ($user->active && strtolower($user->email) === strtolower($email)) {
return $user;
}
}
return null;
}
}
$users = new InMemoryUserRepository(
new User(1, 'ada@example.com', true),
new User(2, 'grace@example.com', false),
);
$user = $users->findActiveByEmail('ADA@example.com');
echo $user?->id ?? 'missing';
echo PHP_EOL;
// Prints:
// 1
A production implementation might use SQL, an ORM, or an external identity service. The application code still asks the same question: find the active user by email.
Repository Versus ORM Model
Frameworks can blur the boundary. Laravel Eloquent models already provide query methods and persistence behavior. Doctrine uses repositories and an entity manager. A custom PDO application might define repositories explicitly from the beginning.
A repository is still useful when you want application-specific query names, consistent scoping, and a smaller dependency surface. For example, a controller that calls Order::where('user_id', $id)->whereNull('deleted_at')->whereIn('status', [...]) knows too much about storage details. OrderRepository::forAccountHistory($accountId) gives that query one owner.
Do not fight the framework for no reason. In a small Laravel application, using Eloquent directly in simple controllers can be acceptable. Add repositories when repeated queries, complicated scopes, transaction coordination, or test boundaries justify them.
Repositories And Transactions
A repository should usually not secretly start and commit a transaction for every method. Transaction ownership belongs at the use-case boundary when several reads, writes, and side effects must be coordinated.
For example, cancelling an order may load the order, change state, save it, write an outbox event, and commit. The application service should own that transaction because it knows the whole operation. The repository can participate by loading and saving through the same database connection or unit of work.
A repository method may use a transaction internally for one storage operation when that operation is truly self-contained. But if several repositories need to coordinate, hiding transactions inside each repository can create partial commits and confusing rollback behavior.
Query Repositories And Write Repositories
Not every repository has to load domain objects. Read-heavy applications often use query repositories that return read models optimized for screens, reports, or APIs. A dashboard may need RecentOrderSummary rows rather than full Order objects with behavior.
Separating read repositories from write repositories can make intent clearer. A write repository preserves domain invariants and persistence behavior. A read repository answers a query efficiently. This is not full CQRS by default; it is simply honest naming.
Avoid forcing every report through domain object hydration if the report only needs five columns and no behavior. Also avoid putting writes into a read repository just because a convenient query builder is nearby.
Generic Repository Trap
A generic repository promises one reusable CRUD wrapper for every entity. It often starts with methods such as find($id), findAll(), save($entity), and delete($entity). That can remove some duplication, but it can also hide the useful queries that make a repository valuable.
If every application service calls findBy(['status' => 'paid', 'deleted_at' => null]), business meaning is still scattered. The generic wrapper has not created a better boundary. It has only moved query arrays from ORM calls into repository calls.
Prefer repositories with meaningful methods. Shared lower-level helpers can still exist for mapping or database access, but the public repository interface should speak application language.
Mapping And Identity
Repositories often map between database rows and objects. That mapping needs clear rules for IDs, nullability, money, dates, enums, JSON columns, deleted records, and related objects. Mapping errors can be subtle because the code may return an object that looks valid but has wrong semantics.
For domain objects with identity, decide whether repeated loads in one use case should return the same object instance. Doctrine's identity map handles this through its entity manager. A simple PDO repository may not. That difference matters if two parts of a use case mutate what they think is the same object.
Do not let repository mapping become a hidden place for business decisions. Converting a database status string into an enum is mapping. Deciding that an order is cancellable is domain policy.
Repositories And External APIs
A repository does not have to use a database. It can hide an external system when the application treats that system as a collection of records. For example, CustomerDirectory might fetch users from an identity provider, or DocumentRepository might retrieve metadata from a document service.
Use the term carefully. If the external system performs commands with side effects, a gateway, client, adapter, or service may be clearer. A repository is strongest when the caller thinks in terms of finding, listing, saving, or removing application objects.
External repositories need timeout, retry, caching, and failure semantics just like any integration. Returning null for both not found and provider timeout is a serious defect.
Testing Repositories
Application services can test against fake repositories when the repository interface is small and meaningful. This keeps business tests fast and focused.
The real repository implementation needs integration tests against the actual persistence technology or a close local equivalent. Test mapping, not-found behavior, scoping, sorting, pagination, transaction participation, and important query plans. A fake repository can prove application logic, but it cannot prove SQL correctness.
Be careful that fakes do not implement different semantics. If the real repository compares emails case-insensitively, the fake should do the same or the application test may lie. When semantics are complex, prefer integration tests for the real repository and keep fakes minimal.
Common Failure Modes
One failure mode is the anemic generic repository. It hides the ORM but exposes generic query arrays that still leak storage thinking into application code.
Another failure mode is a repository that becomes a business service. If OrderRepository::cancelAndEmailCustomer() changes state and sends email, the repository now owns use-case orchestration. That work belongs in an application service or command handler.
A third failure mode is returning raw database rows from a domain repository. That couples callers to column names and mapping decisions. If callers need arrays for a report, name the method and return type as a read model.
A fourth failure mode is ignoring performance. A repository can hide an N+1 query just as easily as it can hide clean SQL. Review query counts and plans for important methods.
Review Criteria
When reviewing a repository, ask what application question it answers. Check that method names are meaningful, return types are clear, and failure cases are distinguishable. null for not found can be fine; null for timeout, permission denied, and not found is not fine.
Check transaction ownership. If several repositories are used in one operation, verify how they share connection, entity manager, or unit of work. Check whether saves happen immediately or are flushed later. Frameworks differ here, and the repository interface should not hide behavior that callers must understand.
Check tests at the correct level. Application behavior can use fakes. SQL, mapping, constraints, pagination, and performance need real persistence checks.
What To Check
Before moving on, make sure you can:
- explain repository as an application-owned persistence boundary
- name repository methods after application needs rather than generic storage mechanics
- distinguish repositories from services, adapters, and ORM models
- keep transaction ownership at the use-case boundary when operations span several changes
- decide when read repositories should return read models instead of domain objects
- avoid generic CRUD wrappers that hide useful queries
- test both application services with fakes and real repositories with integration checks
After this lesson, you should be able to look at data access in a PHP feature and decide whether direct ORM usage is clear enough, whether a focused repository would improve ownership, and what semantics the repository must preserve.
Practice
Task: Design An Order Repository
A controller currently builds a query to find an order that belongs to the current account, is not deleted, and is in a state that can be shown on the account history page.
Design a repository method for this use case.
Requirements
Name the repository, method, input values, return type, and not-found behavior. Explain what query details should be hidden from the controller.
Check your work
The method name should describe the application question, not the database columns.
Show solution
The method should apply account scoping, deleted-record filtering, and allowed history statuses internally. The controller should not know the deleted_at column, status list, tenant column, join details, or eager-loading choices.
Returning null for a missing or inaccessible order is acceptable if the application intentionally treats both as not found. Temporary database failure should not return null; it should surface as an operational exception so the controller can produce a server error and logs.
Task: Review A Generic Repository
A project has one GenericRepository with findBy(array $criteria), save(object $entity), and delete(object $entity). Application services pass arrays such as ['status' => 'paid', 'deleted_at' => null] throughout the codebase.
Review the design and propose a better repository boundary.
Requirements
Explain what is wrong, what to keep if useful, and how to name focused methods.
Check your work
The answer should preserve useful lower-level helpers without making generic arrays the public application boundary.
Show solution
The public boundary is too generic. Application services still know column names, deleted-record conventions, and status values. The generic repository hides the ORM or database object but does not express application meaning.
Keep lower-level mapping or database helpers internally if they remove real duplication. Replace public generic calls with focused repositories and methods such as PaidOrderRepository::readyForSettlement(), OrderRepository::findForCancellation(), or InvoiceRepository::overdueForReminder().
Each method should own the relevant scoping, status filters, sorting, pagination, and mapping. Application services then ask meaningful questions instead of assembling storage criteria arrays.
Task: Test A Repository Boundary
Plan tests for both the application service and the real repository.
Requirements
Include what the fake should prove, what the integration test should prove, and one semantic mismatch risk between fake and real repository.
Check your work
The answer should not claim that a fake repository proves SQL correctness.
Show solution
Application service tests can use an in-memory fake to prove behavior such as successful login for an active user, rejection for a missing user, and rejection for an inactive user. These tests keep the use case fast and independent of database setup.
The real repository needs integration tests against the database or a close local equivalent. Test that findActiveByEmail() returns only active users, handles case rules as intended, returns null when missing, respects tenant or deleted-record filters if relevant, and uses the expected indexes for production-size queries.
A semantic mismatch risk is email comparison. The fake might compare case-sensitively while the real database collation compares case-insensitively, or the reverse. Keep fake behavior aligned with the repository contract and cover important semantics in real repository tests.