Repository pattern — abstracting data access
Concept
The Repository pattern, as defined by Eric Evans in Domain-Driven Design, is a collection-like interface for accessing domain objects. From the caller's perspective, a Repository looks like an in-memory collection of objects — you add to it, remove from it, and query it, without any awareness of how those objects are stored. The concrete implementation handles the database interaction while the interface remains pure domain language.
In Laravel projects, the Repository pattern is often introduced to abstract Eloquent away, making controllers and services depend on an interface rather than a concrete Eloquent class. The benefit is that you can swap the implementation — replace MySQL with PostgreSQL, Eloquent with raw PDO, or a real database with an in-memory array for testing. The interface acts as the boundary between your application logic and your storage mechanism.
The critical distinction is thin vs fat repositories. A thin repository has a narrow interface: findById, findByEmail, save, delete, and perhaps a handful of domain-specific finders. It does not expose query-building capabilities to callers. A fat repository grows an ever-expanding API: findActiveUsersWithMoreThanTenOrdersWhoHaveNotLoggedInForThirtyDays. Fat repositories become a second query layer that is harder to test and no easier to swap than just using Eloquent directly.
Knowing when NOT to use the Repository pattern is as important as knowing when to use it. For simple CRUD applications where Eloquent is the obvious and only storage mechanism, adding a Repository layer is pure indirection overhead — it adds interfaces, bindings, and files without providing a meaningful abstraction. Repositories pay off when: you have complex domain logic, you need testability without a database, you genuinely might swap storage backends, or you are practicing DDD with proper aggregates. For a typical Laravel CRUD app with standard Eloquent usage, using Eloquent directly in services is entirely reasonable.
| Approach | Testability | Flexibility | Boilerplate | Best for |
|---|---|---|---|---|
| Eloquent in controllers | Low | Low | None | Quick prototypes |
| Eloquent in services | Medium | Medium | Low | Most Laravel apps |
| Repository + Eloquent | High | High | Medium-high | DDD, complex domains |
| Repository + Raw SQL | High | Very high | High | Performance-critical systems |
Code Example
<?php
declare(strict_types=1);
// app/Repositories/Contracts/UserRepositoryInterface.php
// The interface is pure domain language — no Eloquent types leak through
namespace App\Repositories\Contracts;
use App\Domain\User\User;
interface UserRepositoryInterface
{
public function findById(int $id): ?User;
public function findByEmail(string $email): ?User;
public function findActiveUsers(): array;
public function save(User $user): void;
public function delete(int $id): void;
}
// app/Repositories/EloquentUserRepository.php
// The concrete implementation knows about Eloquent — the interface does not
namespace App\Repositories;
use App\Domain\User\User as DomainUser;
use App\Models\User as EloquentUser;
use App\Repositories\Contracts\UserRepositoryInterface;
class EloquentUserRepository implements UserRepositoryInterface
{
public function findById(int $id): ?DomainUser
{
$model = EloquentUser::find($id);
return $model ? $this->toDomain($model) : null;
}
public function findByEmail(string $email): ?DomainUser
{
$model = EloquentUser::where('email', $email)->first();
return $model ? $this->toDomain($model) : null;
}
public function findActiveUsers(): array
{
return EloquentUser::where('active', true)
->orderBy('created_at', 'desc')
->get()
->map(fn($m) => $this->toDomain($m))
->all();
}
public function save(DomainUser $user): void
{
EloquentUser::updateOrCreate(
['id' => $user->id],
[
'name' => $user->name,
'email' => $user->email,
'active' => $user->isActive(),
]
);
}
public function delete(int $id): void
{
EloquentUser::destroy($id);
}
private function toDomain(EloquentUser $model): DomainUser
{
return new DomainUser(
id: $model->id,
name: $model->name,
email: $model->email,
active: (bool) $model->active,
);
}
}
// app/Repositories/InMemoryUserRepository.php
// Used in tests — no database required, runs in milliseconds
namespace App\Repositories;
use App\Domain\User\User;
use App\Repositories\Contracts\UserRepositoryInterface;
class InMemoryUserRepository implements UserRepositoryInterface
{
/** @var array<int, User> */
private array $store = [];
private int $nextId = 1;
public function findById(int $id): ?User
{
return $this->store[$id] ?? null;
}
public function findByEmail(string $email): ?User
{
foreach ($this->store as $user) {
if ($user->email === $email) {
return $user;
}
}
return null;
}
public function findActiveUsers(): array
{
return array_values(array_filter(
$this->store,
fn(User $u) => $u->isActive()
));
}
public function save(User $user): void
{
if ($user->id === null) {
$user = $user->withId($this->nextId++);
}
$this->store[$user->id] = $user;
}
public function delete(int $id): void
{
unset($this->store[$id]);
}
}
// app/Providers/AppServiceProvider.php — bind interface to implementation
use App\Repositories\Contracts\UserRepositoryInterface;
use App\Repositories\EloquentUserRepository;
$this->app->bind(UserRepositoryInterface::class, EloquentUserRepository::class);
// In tests, swap to the fast in-memory version:
// $this->app->bind(UserRepositoryInterface::class, InMemoryUserRepository::class);Interview Q&A
Q: What problem does the Repository pattern solve, and when is it not worth using?
The Repository pattern decouples your application logic from its storage mechanism. Instead of depending on Eloquent directly, your services depend on an interface, which means you can swap the concrete implementation for tests (an in-memory fake), for performance (raw SQL), or for a different database entirely. It is not worth using for simple CRUD applications where Eloquent is the definitive storage backend and no domain complexity justifies the abstraction — you add interfaces, service container bindings, and extra classes without gaining meaningful flexibility.
Q: What is the difference between a thin and a fat repository, and why does it matter?
A thin repository has a small, stable interface: findById, findByEmail, save, delete. Callers express their intent in domain terms. A fat repository grows query-specific methods for every use case, eventually becoming a wrapper around query-building that is harder to test and no simpler than using the ORM directly. Fat repositories also tend to leak query logic into the interface — the whole point was to hide that. Keep repositories small; push complex query composition into query objects or specifications if needed.
Q: How do you handle pagination and complex filtering in a Repository without bloating its interface?
One approach is to accept a Specification or Criteria object — a value object that encapsulates filter parameters. The repository's findByCriteria(UserCriteria $criteria) method applies those filters internally. Another approach, common in simpler Laravel apps, is to accept an array of filter options and build the query internally. A third approach is to expose a query scope layer separately from the repository and only use the repository for single-aggregate retrieval and persistence. The right choice depends on how important swappability is — if testability is the main goal, even a Fake that accepts filter arrays is often good enough.