Laravel Repositories and Data Layers: Clean Architecture in Action
Hey Laravel developers! 👋
Ever looked at your controller and thought "This is getting messy"? Or tried to test your app only to realize everything's tangled up with Eloquent models? You're not alone, and there's a elegant solution: the Repository pattern and proper data layers.
Today, we're diving deep into clean architecture with Laravel. I'll show you how to build maintainable, testable, and scalable applications that won't make you cry when you need to make changes six months from now.
Table Of Contents
- TL;DR - Why Should I Care?
- What the Heck is the Repository Pattern?
- Why Bother with Clean Architecture?
- Building Your First Repository
- Adding a Service Layer for Business Logic
- Advanced Repository Patterns
- Testing Your Repository Layer
- When NOT to Use Repositories
- Common Pitfalls (And How to Avoid Them)
- Best Practices Checklist
- Real-World Example: E-commerce Order System
- Wrapping Up
TL;DR - Why Should I Care?
Repositories abstract your data access logic, making your code testable and flexible. Data layers separate concerns so your business logic doesn't depend on Laravel's ORM. The result? Code that's easier to test, maintain, and scale.
When to use: Complex applications, team projects, apps that need extensive testing. When to skip: Simple CRUD apps, prototypes, personal projects where speed matters more than structure.
What the Heck is the Repository Pattern?
Think of repositories as librarians for your data. Instead of wandering around the library (database) looking for books (records) yourself, you ask the librarian (repository) to find what you need. The librarian knows where everything is and how to get it efficiently.
In Laravel terms, instead of this mess in your controller:
// 😱 Controller doing too much
public function index()
{
$users = User::with('posts')
->where('active', true)
->where('created_at', '>', now()->subDays(30))
->orderBy('name')
->paginate(20);
return view('users.index', compact('users'));
}
You get this clean, testable code:
// 😎 Clean and focused
public function index(UserRepositoryInterface $userRepo)
{
$users = $userRepo->getActiveRecentUsers();
return view('users.index', compact('users'));
}
Much better, right?
Why Bother with Clean Architecture?
Let me tell you a story. I once worked on a Laravel project where controllers were 500+ lines long, models had 30+ methods, and testing was basically impossible. Sound familiar? 😅
Here's what clean architecture with repositories gives you:
1. Testability That Actually Works
No more mocking Eloquent models or setting up databases for unit tests:
// Easy to test!
public function test_user_dashboard_shows_recent_activity()
{
$mockRepo = Mockery::mock(UserRepositoryInterface::class);
$mockRepo->shouldReceive('getRecentActivity')
->with(123)
->andReturn(collect(['activity1', 'activity2']));
$this->app->instance(UserRepositoryInterface::class, $mockRepo);
// Test your controller without touching the database
}
2. Business Logic Independence
Your core business rules don't care if you're using MySQL, PostgreSQL, or even a CSV file:
// Business logic stays clean
class UserService
{
public function promoteToVip(User $user): bool
{
if ($user->totalSpent() < 1000) {
throw new InsufficientSpendingException();
}
$user->setVipStatus(true);
$this->userRepository->save($user);
return true;
}
}
3. Flexibility for the Future
Need to switch from Eloquent to a REST API? No problem. Your controllers and services don't need to change:
// Same interface, different implementation
class ApiUserRepository implements UserRepositoryInterface
{
public function find(int $id): ?User
{
// Fetch from external API instead of database
$response = Http::get("https://api.example.com/users/{$id}");
return $this->transformToUser($response->json());
}
}
Building Your First Repository
Let's start simple and build up. We'll create a user repository that handles all user-related data operations.
Step 1: Define the Contract
Always start with an interface. This is your contract that says "here's what a user repository must do":
<?php
namespace App\Contracts;
use App\Models\User;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
interface UserRepositoryInterface
{
public function find(int $id): ?User;
public function findByEmail(string $email): ?User;
public function create(array $data): User;
public function update(User $user, array $data): User;
public function delete(User $user): bool;
public function getActiveUsers(): Collection;
public function getRecentUsers(int $days = 30): Collection;
public function paginateActiveUsers(int $perPage = 15): LengthAwarePaginator;
}
Step 2: Implement the Repository
Now let's build the actual repository that implements this contract:
<?php
namespace App\Repositories;
use App\Contracts\UserRepositoryInterface;
use App\Models\User;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class EloquentUserRepository implements UserRepositoryInterface
{
public function find(int $id): ?User
{
return User::find($id);
}
public function findByEmail(string $email): ?User
{
return User::where('email', $email)->first();
}
public function create(array $data): User
{
return User::create($data);
}
public function update(User $user, array $data): User
{
$user->update($data);
return $user->fresh();
}
public function delete(User $user): bool
{
return $user->delete();
}
public function getActiveUsers(): Collection
{
return User::where('is_active', true)
->orderBy('name')
->get();
}
public function getRecentUsers(int $days = 30): Collection
{
return User::where('created_at', '>=', now()->subDays($days))
->orderBy('created_at', 'desc')
->get();
}
public function paginateActiveUsers(int $perPage = 15): LengthAwarePaginator
{
return User::where('is_active', true)
->orderBy('name')
->paginate($perPage);
}
}
Step 3: Wire It Up with Service Provider
Tell Laravel how to resolve your repository:
<?php
namespace App\Providers;
use App\Contracts\UserRepositoryInterface;
use App\Repositories\EloquentUserRepository;
use Illuminate\Support\ServiceProvider;
class RepositoryServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(
UserRepositoryInterface::class,
EloquentUserRepository::class
);
}
}
Don't forget to register it in config/app.php
:
'providers' => [
// Other providers...
App\Providers\RepositoryServiceProvider::class,
],
Step 4: Use It in Your Controller
Now your controllers become focused and clean:
<?php
namespace App\Http\Controllers;
use App\Contracts\UserRepositoryInterface;
use App\Http\Requests\CreateUserRequest;
use App\Http\Requests\UpdateUserRequest;
class UserController extends Controller
{
public function __construct(
private UserRepositoryInterface $userRepository
) {}
public function index()
{
$users = $this->userRepository->paginateActiveUsers();
return view('users.index', compact('users'));
}
public function show(int $id)
{
$user = $this->userRepository->find($id);
if (!$user) {
abort(404);
}
return view('users.show', compact('user'));
}
public function store(CreateUserRequest $request)
{
$user = $this->userRepository->create($request->validated());
return redirect()->route('users.show', $user);
}
public function update(UpdateUserRequest $request, int $id)
{
$user = $this->userRepository->find($id);
if (!$user) {
abort(404);
}
$this->userRepository->update($user, $request->validated());
return redirect()->route('users.show', $user);
}
}
Look how clean that is! 🎉
Adding a Service Layer for Business Logic
Repositories handle data access, but where does business logic go? Enter the service layer!
Creating a User Service
<?php
namespace App\Services;
use App\Contracts\UserRepositoryInterface;
use App\Models\User;
use App\Exceptions\InvalidUserDataException;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use App\Mail\WelcomeEmail;
class UserService
{
public function __construct(
private UserRepositoryInterface $userRepository
) {}
public function createUser(array $userData): User
{
// Business validation
if (!$this->isValidEmail($userData['email'])) {
throw new InvalidUserDataException('Invalid email format');
}
// Hash password
$userData['password'] = Hash::make($userData['password']);
// Create user
$user = $this->userRepository->create($userData);
// Send welcome email
Mail::to($user)->send(new WelcomeEmail($user));
return $user;
}
public function promoteToVip(User $user): bool
{
// Business rule: Must have spent at least $1000
if ($user->total_spent < 1000) {
return false;
}
$this->userRepository->update($user, [
'is_vip' => true,
'vip_since' => now()
]);
return true;
}
public function deactivateInactiveUsers(int $days = 365): int
{
$inactiveUsers = $this->userRepository->getInactiveUsers($days);
$count = 0;
foreach ($inactiveUsers as $user) {
$this->userRepository->update($user, ['is_active' => false]);
$count++;
}
return $count;
}
private function isValidEmail(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
}
Using the Service in Your Controller
public function store(CreateUserRequest $request, UserService $userService)
{
try {
$user = $userService->createUser($request->validated());
return redirect()
->route('users.show', $user)
->with('success', 'User created successfully!');
} catch (InvalidUserDataException $e) {
return back()
->withErrors(['email' => $e->getMessage()])
->withInput();
}
}
Advanced Repository Patterns
Ready for some next-level stuff? Let's add some sophisticated features.
Generic Base Repository
Avoid repeating yourself with a base repository:
<?php
namespace App\Repositories;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
abstract class BaseRepository
{
protected Model $model;
public function __construct(Model $model)
{
$this->model = $model;
}
public function find(int $id): ?Model
{
return $this->model->find($id);
}
public function all(): Collection
{
return $this->model->all();
}
public function create(array $data): Model
{
return $this->model->create($data);
}
public function update(Model $model, array $data): Model
{
$model->update($data);
return $model->fresh();
}
public function delete(Model $model): bool
{
return $model->delete();
}
public function paginate(int $perPage = 15): LengthAwarePaginator
{
return $this->model->paginate($perPage);
}
public function where(string $column, $value): Collection
{
return $this->model->where($column, $value)->get();
}
}
Now your specific repositories become much simpler:
class EloquentUserRepository extends BaseRepository implements UserRepositoryInterface
{
public function __construct(User $user)
{
parent::__construct($user);
}
public function findByEmail(string $email): ?User
{
return $this->model->where('email', $email)->first();
}
public function getActiveUsers(): Collection
{
return $this->where('is_active', true);
}
// Only implement the methods that are specific to users
}
Repository with Criteria Pattern
Add flexible filtering without bloating your repository:
<?php
namespace App\Repositories\Criteria;
interface CriteriaInterface
{
public function apply($query);
}
class ActiveUsersCriteria implements CriteriaInterface
{
public function apply($query)
{
return $query->where('is_active', true);
}
}
class RecentUsersCriteria implements CriteriaInterface
{
public function __construct(private int $days = 30) {}
public function apply($query)
{
return $query->where('created_at', '>=', now()->subDays($this->days));
}
}
Use criteria in your repository:
class EloquentUserRepository extends BaseRepository
{
private array $criteria = [];
public function pushCriteria(CriteriaInterface $criteria): self
{
$this->criteria[] = $criteria;
return $this;
}
public function applyCriteria()
{
$query = $this->model->query();
foreach ($this->criteria as $criteria) {
$query = $criteria->apply($query);
}
$this->criteria = []; // Reset criteria
return $query;
}
public function get(): Collection
{
return $this->applyCriteria()->get();
}
}
Now you can chain criteria like a boss:
$activeRecentUsers = $userRepository
->pushCriteria(new ActiveUsersCriteria())
->pushCriteria(new RecentUsersCriteria(7))
->get();
Testing Your Repository Layer
One of the biggest benefits of repositories is testing. Here's how to do it right:
Unit Testing the Repository
<?php
namespace Tests\Unit\Repositories;
use App\Models\User;
use App\Repositories\EloquentUserRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class EloquentUserRepositoryTest extends TestCase
{
use RefreshDatabase;
private EloquentUserRepository $repository;
protected function setUp(): void
{
parent::setUp();
$this->repository = new EloquentUserRepository(new User());
}
public function test_it_can_create_a_user()
{
$userData = [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123'
];
$user = $this->repository->create($userData);
$this->assertInstanceOf(User::class, $user);
$this->assertEquals('john@example.com', $user->email);
$this->assertDatabaseHas('users', ['email' => 'john@example.com']);
}
public function test_it_can_find_user_by_email()
{
$user = User::factory()->create(['email' => 'test@example.com']);
$foundUser = $this->repository->findByEmail('test@example.com');
$this->assertNotNull($foundUser);
$this->assertEquals($user->id, $foundUser->id);
}
public function test_it_returns_null_for_non_existent_email()
{
$user = $this->repository->findByEmail('nonexistent@example.com');
$this->assertNull($user);
}
}
Testing Services with Mock Repositories
<?php
namespace Tests\Unit\Services;
use App\Contracts\UserRepositoryInterface;
use App\Services\UserService;
use App\Models\User;
use Mockery;
use Tests\TestCase;
class UserServiceTest extends TestCase
{
public function test_it_promotes_eligible_user_to_vip()
{
// Arrange
$user = new User(['total_spent' => 1500]);
$mockRepository = Mockery::mock(UserRepositoryInterface::class);
$mockRepository->shouldReceive('update')
->once()
->with($user, ['is_vip' => true, 'vip_since' => Mockery::type('Carbon\Carbon')])
->andReturn($user);
$userService = new UserService($mockRepository);
// Act
$result = $userService->promoteToVip($user);
// Assert
$this->assertTrue($result);
}
public function test_it_does_not_promote_ineligible_user()
{
// Arrange
$user = new User(['total_spent' => 500]);
$mockRepository = Mockery::mock(UserRepositoryInterface::class);
$mockRepository->shouldNotReceive('update');
$userService = new UserService($mockRepository);
// Act
$result = $userService->promoteToVip($user);
// Assert
$this->assertFalse($result);
}
}
When NOT to Use Repositories
Let's keep it real - repositories aren't always the answer. Here's when to skip them:
Simple CRUD Applications
If you're building a basic blog or todo app, this might be overkill:
// This is fine for simple apps
public function store(Request $request)
{
Post::create($request->validated());
return redirect()->route('posts.index');
}
Rapid Prototyping
When you need to move fast and test ideas, don't let architecture slow you down.
Small Teams/Solo Projects
If it's just you or a couple developers, the overhead might not be worth it.
Apps with Minimal Business Logic
If your app is mostly forms and displays, stick with simple controllers and models.
Common Pitfalls (And How to Avoid Them)
1. Repository Bloat
Don't create monster repositories with 50+ methods:
// 😱 This is getting out of hand
interface UserRepositoryInterface
{
public function findActiveUsers();
public function findInactiveUsers();
public function findVipUsers();
public function findRecentUsers();
public function findOldUsers();
public function findUsersByCity();
public function findUsersByCountry();
// ... 40 more methods
}
Better approach: Use criteria pattern or create specialized repositories.
2. Putting Business Logic in Repositories
Repositories should only handle data access:
// 😱 Don't do this
public function createUser(array $data): User
{
// This belongs in a service!
if ($data['age'] < 18) {
throw new TooYoungException();
}
$data['password'] = Hash::make($data['password']);
Mail::to($data['email'])->send(new WelcomeEmail());
return User::create($data);
}
3. Over-Abstracting Everything
Not every model needs a repository:
// Probably overkill for a simple lookup table
class CountryRepository
{
public function findByCode(string $code): ?Country
{
return Country::where('code', $code)->first();
}
}
Best Practices Checklist
Here's your go-to checklist for repository implementation:
✅ Always start with interfaces ✅ Keep repositories focused on data access only ✅ Use services for business logic ✅ Write tests for both repositories and services ✅ Use dependency injection consistently ✅ Consider criteria pattern for complex filtering ✅ Don't create repositories for every model ✅ Keep methods focused and single-purpose ✅ Use meaningful names for methods ✅ Document complex query logic
Real-World Example: E-commerce Order System
Let's put it all together with a realistic example:
// Order Repository Interface
interface OrderRepositoryInterface
{
public function find(int $id): ?Order;
public function findByUser(User $user): Collection;
public function create(array $data): Order;
public function getRecentOrders(int $days = 30): Collection;
public function getPendingOrders(): Collection;
}
// Order Service
class OrderService
{
public function __construct(
private OrderRepositoryInterface $orderRepository,
private InventoryService $inventoryService,
private PaymentService $paymentService
) {}
public function createOrder(User $user, array $items): Order
{
// Validate inventory
foreach ($items as $item) {
if (!$this->inventoryService->isAvailable($item['product_id'], $item['quantity'])) {
throw new InsufficientInventoryException();
}
}
// Calculate total
$total = $this->calculateTotal($items);
// Create order
$order = $this->orderRepository->create([
'user_id' => $user->id,
'total' => $total,
'status' => 'pending'
]);
// Reserve inventory
$this->inventoryService->reserve($items);
return $order;
}
public function processPayment(Order $order, string $paymentMethod): bool
{
try {
$this->paymentService->charge($order->total, $paymentMethod);
$this->orderRepository->update($order, ['status' => 'paid']);
return true;
} catch (PaymentException $e) {
$this->orderRepository->update($order, ['status' => 'failed']);
return false;
}
}
}
// Clean Controller
class OrderController extends Controller
{
public function store(CreateOrderRequest $request, OrderService $orderService)
{
try {
$order = $orderService->createOrder(
auth()->user(),
$request->get('items')
);
return response()->json(['order_id' => $order->id], 201);
} catch (InsufficientInventoryException $e) {
return response()->json(['error' => 'Insufficient inventory'], 400);
}
}
}
Beautiful, isn't it? Each class has a single responsibility, everything is testable, and the code tells a clear story.
Wrapping Up
Repository pattern and clean architecture aren't silver bullets, but they're incredibly powerful tools when used wisely. They transform chaotic, hard-to-test code into organized, maintainable applications.
Start small - pick one messy controller and refactor it with a repository. Feel the difference. Then gradually apply the pattern where it makes sense.
Remember:
- Repositories = Data access abstraction
- Services = Business logic coordination
- Controllers = HTTP request/response handling
- Models = Domain entities
Your future self (and your teammates) will thank you for writing clean, testable code. Plus, you'll look like a architecture wizard in code reviews! 🧙♂️
What's Next?
Try implementing a simple repository for your current project. Start with the messiest controller you have and see how much cleaner it becomes. Don't try to refactor everything at once - take it step by step.
Want to go deeper?
- Domain Driven Design (DDD) patterns
- CQRS (Command Query Responsibility Segregation)
- Event sourcing with Laravel
- Advanced testing strategies
Drop a comment and let me know how your repository refactoring goes! I love hearing about real-world improvements. 🚀
Add Comment
No comments yet. Be the first to comment!