Navigation

Laravel

Laravel Repository Pattern: Clean Architecture Guide 2025

#laravel #php
Learn Laravel Repository pattern and clean architecture. Build testable, maintainable apps with proper data layers. Complete guide with code examples, testing strategies, and real-world implementation tips.

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?

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 interfacesKeep repositories focused on data access onlyUse services for business logicWrite tests for both repositories and servicesUse dependency injection consistentlyConsider criteria pattern for complex filteringDon't create repositories for every modelKeep methods focused and single-purposeUse meaningful names for methodsDocument 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. 🚀

Share this article

Add Comment

No comments yet. Be the first to comment!

More from Laravel