Repository Pattern: From Confusion to Clarity


Introduction
When I first encountered the Repository Pattern in my development journey, I will admit, I was sceptical. Another abstraction layer? More interfaces to maintain? Why not just use the ORM directly? But after implementing it across multiple projects and experiencing both its benefits and pitfalls firsthand, I have gained a deep appreciation for this architectural pattern and learned when it truly shines.
This article shares practical experience with the Repository Pattern, including real challenges faced, common mistakes, and valuable lessons learned along the way.
What is the Repository Pattern?
The Repository Pattern acts as an in-memory collection of domain objects, providing a more object-oriented view of the persistence layer. It encapsulates the logic needed to access data sources, centralizing common data access functionality and promoting better maintainability.
<?php
// Basic repository interface
interface UserRepositoryInterface
{
public function findById(int $id): ?User;
public function getAll(): Collection;
public function create(array $data): User;
public function update(int $id, array $data): bool;
public function delete(int $id): bool;
}
My Initial Scepticism
When I first started using Eloquent ORM, I loved the simplicity of using models directly in my controllers and services. Why add another layer when Eloquent already provided such an elegant interface?
<?php
// Old approach - direct Eloquent usage in controller
class UserController extends Controller
{
public function show($id)
{
$user = User::find($id);
if (!$user) {
return response()->json(['error' => 'User not found'], 404);
}
return response()->json($user);
}
public function index()
{
$users = User::where('active', true)->get();
return response()->json($users);
}
}
This seemed clean and straightforward. Little did I know the challenges that would emerge as my applications grew in complexity.
The Turning Point: A Real Project Challenge
The wake-up call came during a project where multiple data sources were needed, a MySQL database for transactional data and Redis cache for frequently accessed user sessions. Suddenly, controllers were tightly coupled to specific data access logic, and testing became a nightmare.
The Problem
<?php
class UserService
{
protected $redis;
public function __construct()
{
$this->redis = Redis::connection();
}
// This service now knew too much about data access
public function getUser($id)
{
// Check cache first
$cacheKey = "user:{$id}";
$cached = $this->redis->get($cacheKey);
if ($cached) {
return json_decode($cached, true);
}
// Fallback to database
$user = User::find($id);
if ($user) {
// Cache for next time
$this->redis->setex($cacheKey, 900, json_encode($user));
}
return $user;
}
}
This approach violated the Single Responsibility Principle and made testing incredibly difficult. I needed a better abstraction.
Implementing the Repository Pattern
Step 1: Defining the Contract
Creating focused repository interfaces was the starting point:
<?php
interface UserRepositoryInterface
{
public function findById(int $id): ?User;
public function findByEmail(string $email): ?User;
public function getActiveUsers(): Collection;
public function create(array $data): User;
public function update(int $id, array $data): bool;
public function delete(int $id): bool;
public function exists(int $id): bool;
}
Step 2: Implementation with Caching Logic
<?php
namespace App\Repositories;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
class UserRepository implements UserRepositoryInterface
{
const CACHE_TTL = 900; // 15 minutes
public function findById(int $id): ?User
{
$cacheKey = "user:{$id}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($id) {
return User::find($id);
});
}
public function findByEmail(string $email): ?User
{
return User::where('email', $email)->first();
}
public function getActiveUsers(): Collection
{
return Cache::remember('active_users', self::CACHE_TTL, function () {
return User::where('active', true)
->whereNull('deleted_at')
->orderByDesc('last_login_at')
->get();
});
}
public function create(array $data): User
{
$user = User::create($data);
// Clear related cache
Cache::forget('active_users');
return $user;
}
public function update(int $id, array $data): bool
{
$result = User::where('id', $id)->update($data);
// Clear specific cache
Cache::forget("user:{$id}");
Cache::forget('active_users');
return $result > 0;
}
public function delete(int $id): bool
{
$result = User::destroy($id);
// Clear caches
Cache::forget("user:{$id}");
Cache::forget('active_users');
return $result > 0;
}
public function exists(int $id): bool
{
return User::where('id', $id)->exists();
}
}
Step 3: Simplified Service Layer
<?php
namespace App\Services;
use App\Repositories\UserRepositoryInterface;
use App\Http\Resources\UserResource;
class UserService
{
protected $userRepository;
public function __construct(UserRepositoryInterface $userRepository)
{
$this->userRepository = $userRepository;
}
public function getUser(int $id): ?UserResource
{
$user = $this->userRepository->findById($id);
return $user ? new UserResource($user) : null;
}
public function isEmailTaken(string $email): bool
{
return $this->userRepository->findByEmail($email) !== null;
}
public function createUser(array $data): UserResource
{
$user = $this->userRepository->create($data);
// Additional business logic (send welcome email, log activity, etc.)
return new UserResource($user);
}
}
Benefits Experienced
1. Testability Transformed
Testing became significantly easier with proper abstractions:
<?php
namespace Tests\Unit\Services;
use Tests\TestCase;
use App\Services\UserService;
use App\Repositories\UserRepositoryInterface;
use App\Models\User;
use Mockery;
class UserServiceTest extends TestCase
{
public function test_get_user_returns_correct_user()
{
// Arrange
$mockRepository = Mockery::mock(UserRepositoryInterface::class);
$expectedUser = new User([
'id' => 1,
'email' => 'test@example.com',
'name' => 'Test User'
]);
$mockRepository->shouldReceive('findById')
->once()
->with(1)
->andReturn($expectedUser);
$userService = new UserService($mockRepository);
// Act
$result = $userService->getUser(1);
// Assert
$this->assertEquals($expectedUser->email, $result->email);
}
public function test_is_email_taken_returns_true_when_email_exists()
{
// Arrange
$mockRepository = Mockery::mock(UserRepositoryInterface::class);
$existingUser = new User(['email' => 'existing@example.com']);
$mockRepository->shouldReceive('findByEmail')
->once()
->with('existing@example.com')
->andReturn($existingUser);
$userService = new UserService($mockRepository);
// Act
$result = $userService->isEmailTaken('existing@example.com');
// Assert
$this->assertTrue($result);
}
}
2. Flexibility in Data Access
When requirements changed and raw queries were needed for certain operations, only the repository implementation needed modification:
<?php
namespace App\Repositories;
use Illuminate\Support\Facades\DB;
use App\Models\User;
class OptimizedUserRepository implements UserRepositoryInterface
{
public function findById(int $id): ?User
{
$userData = DB::select(
'SELECT id, email, name, active, created_at FROM users WHERE id = ? LIMIT 1',
[$id]
);
return $userData ? new User((array) $userData[0]) : null;
}
public function getActiveUsers(): Collection
{
return collect(DB::select(
'SELECT id, email, name, last_login_at
FROM users
WHERE active = 1 AND deleted_at IS NULL
ORDER BY last_login_at DESC'
))->map(function ($user) {
return new User((array) $user);
});
}
// ... other optimized methods
}
3. Centralized Query Logic
Complex queries were now encapsulated and reusable:
<?php
public function getActiveUsersWithRecentActivity(): Collection
{
return User::where('active', true)
->where('last_login_at', '>', now()->subDays(30))
->with('profile')
->orderByDesc('last_login_at')
->get();
}
public function getUsersWithPostsCount(): Collection
{
return User::withCount('posts')
->having('posts_count', '>', 0)
->orderByDesc('posts_count')
->get();
}
Lessons Learned and Best Practices
1. Keep Repositories Focused
Avoid creating generic repositories that try to do everything:
<?php
// Avoid this - too generic
interface BaseRepositoryInterface
{
public function find($id);
public function all();
public function create(array $data);
public function update($id, array $data);
public function delete($id);
}
// Prefer this - domain-specific
interface UserRepositoryInterface
{
public function findById(int $id): ?User;
public function findByEmail(string $email): ?User;
public function getActiveUsers(): Collection;
public function getUsersWithRecentActivity(): Collection;
public function findUsersInRole(string $role): Collection;
}
2. Don't Over-Abstract
Early implementations often create unnecessary abstractions. Sometimes, simple is better:
<?php
// Sometimes this is perfectly fine for simple scenarios
class SimpleUserController extends Controller
{
public function index()
{
// Simple operations might not need repository abstraction
$users = User::paginate(15);
return view('users.index', compact('users'));
}
public function show(User $user)
{
// Route model binding is elegant for simple cases
return view('users.show', compact('user'));
}
}
3. Consider Service Providers for Binding
Laravel's service container can be used to bind interfaces to implementations:
<?php
// In AppServiceProvider or dedicated RepositoryServiceProvider
class RepositoryServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->bind(UserRepositoryInterface::class, UserRepository::class);
$this->app->bind(OrderRepositoryInterface::class, OrderRepository::class);
$this->app->bind(ProductRepositoryInterface::class, ProductRepository::class);
}
}
Common Pitfalls Encountered
1. Leaky Abstractions
Initial implementations may expose Eloquent-specific features in repository interfaces:
<?php
// Don't do this - exposes Eloquent concerns
public function getQueryBuilder(): Builder;
public function whereHas($relation, $callback);
// Better approach
public function findUsersByCriteria(UserSearchCriteria $criteria): Collection;
2. Anemic Repositories
Early repositories often become just thin wrappers around Eloquent calls:
<?php
// Too simple - doesn't add value
public function findById(int $id): ?User
{
return User::find($id);
}
// Better - encapsulates business logic
public function findActiveUserById(int $id): ?User
{
return User::where('id', $id)
->where('active', true)
->whereNull('deleted_at')
->first();
}
When NOT to Use Repository Pattern
Through experience, I learned that Repository Pattern isn't always the answer:
Simple CRUD applications: If you're building a basic app with straightforward data operations, direct Eloquent usage might be sufficient
Rapid prototyping: During rapid prototyping, Laravel's built-in features can be faster
Small team projects: If your team isn't comfortable with the pattern, the learning curve might slow development
API Resources are enough: Sometimes Laravel's API Resources provide sufficient abstraction
Performance Considerations
Performance implications should be considered:
<?php
// Be careful with N+1 problems
public function getUsersWithProfiles(): Collection
{
return User::with('profile') // Eager loading prevents N+1
->where('active', true)
->get();
}
// Use chunking for large datasets
public function processAllUsers(callable $callback): void
{
User::where('active', true)
->chunk(100, function ($users) use ($callback) {
foreach ($users as $user) {
$callback($user);
}
});
}
// Consider lazy collections for memory efficiency
public function getUsersLazy(): LazyCollection
{
return User::where('active', true)->lazy();
}
// Use database transactions for consistency
public function createUserWithProfile(array $userData, array $profileData): User
{
return DB::transaction(function () use ($userData, $profileData) {
$user = User::create($userData);
$user->profile()->create($profileData);
// Clear relevant caches
Cache::forget('active_users');
return $user;
});
}
Conclusion
The Repository Pattern becomes an invaluable tool in the development toolkit. While it requires upfront investment in design and implementation, the benefits in testability, maintainability, and flexibility prove worthwhile in medium to large applications.
The key is knowing when to apply it. Not every application needs this level of abstraction, but when dealing with complex business logic, multiple data sources, or extensive testing requirements, the Repository Pattern can be a game-changer.
Key Takeaways
Start simple: Don't over-engineer from the beginning
Focus on domain needs: Create repositories that make sense for your business logic
Prioritize testability: The testing benefits alone often justify the pattern
Be pragmatic: Sometimes direct ORM usage is perfectly fine
Consider the team: Ensure your team understands and embraces the pattern
What's your experience with the Repository Pattern? Have you encountered similar challenges or discovered other benefits? Share your thoughts in the comments below!
Subscribe to my newsletter
Read articles from Ayobami Omotayo directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Ayobami Omotayo
Ayobami Omotayo
Hi, I’m Ayobami Omotayo, a full-stack developer and educator passionate about leveraging technology to solve real-world problems and empower communities. I specialize in building dynamic, end-to-end web applications with strong expertise in both frontend and backend development