If you’ve worked on a Laravel application for more than a few months, you’ve probably seen a controller that looks like this:
|
1 2 3 4 5 6 7 8 |
public function index() { $posts = Post::where('status', 'published') ->orderBy('created_at', 'desc') ->with('author', 'comments') ->paginate(10); returnview('posts.index', compact('posts')); } |
It works. It’s also fine — for a while. Then the same query shows up in three other controllers, a teammate adds a slightly different version of it in a fourth, and six months later nobody is sure which one is “correct.” This is the moment most teams start asking about the repository pattern.
This post walks through what the repository pattern actually is, why it matters in a Laravel codebase specifically, and how to implement it cleanly — with real code you can drop into a project today.
What Problem Are We Actually Solving?
Eloquent is one of Laravel’s best features. It’s also very easy to lean on too heavily, because it’s so convenient. Controllers, jobs, and even Blade views end up calling Model::where(...) directly. Over time this creates three concrete problems:
- Duplicated query logic. The same “get active users” query gets rewritten slightly differently in five places.
- Tight coupling to Eloquent. Your business logic is welded to the ORM. Swapping a data source, adding a cache layer, or writing a unit test without hitting a real database becomes painful.
- Hard-to-test code. Testing a controller that calls
Post::where(...)directly usually means spinning up a database, because there’s no seam to inject a fake.
The repository pattern addresses this by inserting one layer between your application logic and your data source.
What Is the Repository Pattern?
At its core, the repository pattern says: don’t talk to your data source directly — talk to a repository instead.
A repository is a class that centralizes data access logic behind a clean, predictable interface. Your controllers and services ask the repository for data (“give me all published posts”) without knowing or caring whether that data comes from Eloquent, a third-party API, a cache, or a flat file.
The diagram below shows the structural difference this creates.
Notice what changed on the right side: the controller no longer talks to Eloquent at all. It depends on an interface — a contract — and Laravel’s service container decides which concrete class fulfills that contract at runtime.
Setting It Up: Step by Step
Let’s build this for a Post model.
Step 1: Define the Contract
Create an interface that describes what operations are available, without saying how they’re implemented.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// app/Repositories/Contracts/PostRepositoryInterface.php namespace App\Repositories\Contracts; use App\Models\Post; use Illuminate\Support\Collection; interface PostRepositoryInterface { public function all(): Collection; public function find(int $id): ?Post; public function findPublished(): Collection; public function create(array $data): Post; public function update(Post $post, array $data): bool; public function delete(Post $post): bool; } |
This interface is the most important file in the whole pattern. It’s the seam that everything else hangs off of.
Step 2: Write the Eloquent Implementation
This is where your actual Eloquent queries live — and only here.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
// app/Repositories/Eloquent/EloquentPostRepository.php namespace App\Repositories\Eloquent; use App\Models\Post; use App\Repositories\Contracts\PostRepositoryInterface; use Illuminate\Support\Collection; class EloquentPostRepository implements PostRepositoryInterface { public function all(): Collection { return Post::with('author')->latest()->get(); } public function find(int $id): ?Post { return Post::find($id); } public function findPublished(): Collection { return Post::where('status', 'published') ->with('author', 'comments') ->latest() ->get(); } public function create(array $data): Post { return Post::create($data); } public function update(Post $post, array $data): bool { return $post->update($data); } public function delete(Post $post): bool { return $post->delete(); } } |
Nothing fancy here — it’s the same Eloquent code you’d normally write, just relocated.
Step 3: Bind the Interface in a Service Provider
Laravel needs to know which concrete class to hand over whenever something asks for PostRepositoryInterface. You do that with a binding, typically in a dedicated service provider.
|
1 |
php artisan make:provider RepositoryServiceProvider |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// app/Providers/RepositoryServiceProvider.php namespace App\Providers; use App\Repositories\Contracts\PostRepositoryInterface; use App\Repositories\Eloquent\EloquentPostRepository; use Illuminate\Support\ServiceProvider; class RepositoryServiceProvider extends ServiceProvider { public function register(): void { $this->app->bind( PostRepositoryInterface::class, EloquentPostRepository::class ); } } |
Don’t forget to register the provider in bootstrap/providers.php (Laravel 11+) or config/app.php (earlier versions).
The diagram below shows how this binding connects everything at runtime.
Step 4: Use It via Dependency Injection
Now your controller depends on the interface, not Eloquent. Laravel’s service container automatically injects the bound implementation.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
// app/Http/Controllers/PostController.php namespace App\Http\Controllers; use App\Repositories\Contracts\PostRepositoryInterface; use Illuminate\Http\Request; class PostController extends Controller { public function __construct( protected PostRepositoryInterface $posts ) {} public function index() { $posts = $this->posts->findPublished(); return view('posts.index', compact('posts')); } public function store(Request $request) { $validated = $request->validate([ 'title' => 'required|string|max:255', 'body' => 'required|string', ]); $post = $this->posts->create($validated); return redirect()->route('posts.show', $post)->with('success', 'Post created.'); } } |
The controller no longer knows that Eloquent exists. If you swapped the data source to an external CMS API tomorrow, this file wouldn’t change at all.
Why This Pays Off: Testing
This is where the pattern usually earns its keep. Because PostController depends on an interface, you can hand it a fake repository in tests instead of hitting a real database.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
// tests/Unit/PostControllerTest.php namespace Tests\Unit; use App\Models\Post; use App\Repositories\Contracts\PostRepositoryInterface; use Tests\TestCase; use Mockery; class PostControllerTest extends TestCase { public function test_index_displays_published_posts(): void { $fakePosts = collect([ new Post(['title' => 'First Post', 'status' => 'published']), new Post(['title' => 'Second Post', 'status' => 'published']), ]); $mockRepo = Mockery::mock(PostRepositoryInterface::class); $mockRepo->shouldReceive('findPublished')->once()->andReturn($fakePosts); $this->app->instance(PostRepositoryInterface::class, $mockRepo); $response = $this->get(route('posts.index')); $response->assertOk(); $response->assertSee('First Post'); } } |
No database, no factories, no seeders — just a fast, isolated test that verifies the controller’s actual responsibility: taking data from the repository and rendering it.
A Few Honest Caveats
The repository pattern isn’t free, and it isn’t always the right call. A few things worth knowing before you adopt it everywhere:
- Eloquent is already an abstraction. Some developers argue that wrapping Eloquent in a repository is adding an abstraction over an abstraction, and that Eloquent’s own query builder is testable enough for most apps via in-memory SQLite.
- It can add boilerplate for simple CRUD. If a model only ever needs basic
find/create/update/delete, a repository can feel like ceremony for its own sake. Some teams build a generic base repository to cut down on repetition here. - It shines most in larger, longer-lived codebases — multi-developer teams, apps that swap data sources, or apps with complex query logic that’s reused across many places. For a small app or weekend project, plain Eloquent calls are often genuinely the better choice.
A reasonable middle ground a lot of teams land on: use repositories for models with real query complexity or multiple data-access patterns, and let trivial models use Eloquent directly.
Wrapping Up
The repository pattern in Laravel isn’t about avoiding Eloquent — it’s about giving your business logic a stable contract to depend on, instead of a concrete ORM implementation. That one decision is what makes your code easier to test, easier to refactor, and easier to reason about as the codebase grows.
Start small: pick the one model in your app whose queries are duplicated the most, wrap it in a repository, and feel out whether the pattern earns its place in your project before rolling it out everywhere.