The problem
The repository pattern is one of the most argued-about topics in the Laravel community. Half the community calls it essential for "real" architecture, the other half calls it pointless ceremony on top of Eloquent. Both have a point. This post shows what a repository concretely solves, what it costs, and when that extra layer pays for itself.
What you'll learn
- What a repository is, and what it isn't
- Which lighter alternatives Laravel already offers before you need a repository
- How to build a repository with an interface and a service container binding
- A real example where the abstraction pays off: a caching decorator
- An honest look at the tradeoffs
Prerequisites
- You're comfortable with Eloquent, including relationships and query scopes
- You understand dependency injection and how the service container binds interfaces to implementations (see Service providers and the service container)
What a repository is
A repository is a layer between your application and your data storage. Your code asks for "the upcoming courses" or "the course with this slug" without knowing how or where that data is fetched. The idea comes from Domain-Driven Design, where domain code isn't supposed to know about the database at all.
The tension in Laravel is that Eloquent is an Active Record implementation. A model already is a gateway to your data, complete with a query builder, relationships and scopes. A repository that only forwards Course::find() adds nothing. A repository earns its place only when it does something Eloquent doesn't do for you:
- Drawing a boundary: your controllers and services don't know Eloquent exists, which helps in large teams or modular domains.
- Swapping or stacking implementations: a different data source, or behaviour like caching or logging, without changing any calling code.
- Centralising complex, reused queries: one place for query logic that's needed in ten places and doesn't fit a scope.
Testability is often cited as the main argument, but in Laravel it's weaker than it sounds. With RefreshDatabase and an in-memory SQLite database, you can already test Eloquent code quickly and realistically.
Step by step
Step 1 — The starting point
A typical controller with query logic inside:
class CourseController extends Controller
{
public function index()
{
$courses = Course::query()
->where('published', true)
->where('starts_at', '>', now())
->withCount('enrollments')
->orderBy('starts_at')
->get();
return view('courses.index', compact('courses'));
}
}
This is fine on its own. It becomes a problem when the same query also shows up in your API controller, a scheduled command and a Livewire component, slightly different each time.
Step 2 — Try a scope first
Before building a repository: a local scope on the model already solves a lot of duplication.
use Illuminate\Database\Eloquent\Builder;
class Course extends Model
{
public function scopeUpcoming(Builder $query): void
{
$query->where('published', true)
->where('starts_at', '>', now())
->orderBy('starts_at');
}
}
$courses = Course::upcoming()->withCount('enrollments')->get();
If this is all you need, stop here. A scope is less code, stays composable, and every Laravel developer understands it immediately.
Step 3 — Define the interface
If you do need a boundary, start with a contract that describes what your application needs, in domain language:
namespace App\Repositories;
use App\Models\Course;
use Illuminate\Support\Collection;
interface CourseRepository
{
public function upcoming(): Collection;
public function findBySlug(string $slug): ?Course;
}
Notice the method names. upcoming() describes a question from your domain, not a database operation.
Step 4 — Build the Eloquent implementation
namespace App\Repositories;
use App\Models\Course;
use Illuminate\Support\Collection;
class EloquentCourseRepository implements CourseRepository
{
public function upcoming(): Collection
{
return Course::upcoming()
->withCount('enrollments')
->get();
}
public function findBySlug(string $slug): ?Course
{
return Course::where('slug', $slug)->first();
}
}
The repository reuses the scope from step 2. Scopes and repositories aren't mutually exclusive.
Step 5 — Bind the interface in the service container
In app/Providers/AppServiceProvider.php:
use App\Repositories\CourseRepository;
use App\Repositories\EloquentCourseRepository;
public function register(): void
{
$this->app->bind(CourseRepository::class, EloquentCourseRepository::class);
}
Step 6 — Inject the repository
use App\Repositories\CourseRepository;
class CourseController extends Controller
{
public function __construct(private CourseRepository $courses) {}
public function index()
{
return view('courses.index', [
'courses' => $this->courses->upcoming(),
]);
}
}
So far you've mostly moved code around. The real payoff comes in the next step.
Step 7 — Where it pays off: a caching decorator
The course list appears on every page and rarely changes. You want to add caching without touching a single controller. Because everything goes through the interface, you simply wrap the existing implementation:
namespace App\Repositories;
use App\Models\Course;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
class CachedCourseRepository implements CourseRepository
{
public function __construct(private CourseRepository $inner) {}
public function upcoming(): Collection
{
return Cache::remember(
'courses.upcoming',
now()->addMinutes(10),
fn () => $this->inner->upcoming()
);
}
public function findBySlug(string $slug): ?Course
{
return $this->inner->findBySlug($slug);
}
}
Update the binding:
public function register(): void
{
$this->app->bind(CourseRepository::class, fn ($app) => new CachedCourseRepository(
$app->make(EloquentCourseRepository::class)
));
}
Every place that uses CourseRepository is now cached. Controllers, commands and tests don't notice a thing. The same principle works for logging, metrics, or moving to an external API as a data source.
Step 8 — The honest tradeoff
A repository is worth it when:
- the same query logic is needed in many places and doesn't fit neatly in a scope;
- you want to stack behaviour around data access, such as caching, logging or a fallback source;
- data comes (partly) from a source other than your database;
- you want a hard boundary between domain and storage in a large team or modular codebase.
Skip it when:
- your repository mostly forwards
find(),all()andcreate()to Eloquent; - your project is small, with one team that knows Eloquent well;
- the main argument is "we might switch databases someday". That rarely happens, and when it does, the repository layer is rarely the hard part.
Common mistakes
1. A generic BaseRepository
// ❌
interface BaseRepository
{
public function all();
public function find(int $id);
public function create(array $data);
public function update(int $id, array $data);
public function delete(int $id);
}
This is a worse version of Eloquent. You lose eager loading, pagination and scopes, or you rebuild them with parameters like find($id, $with = [], $columns = ['*']). A repository should have methods that describe your domain (upcoming(), withOpenSeats()), not generic CRUD.
2. A leaky abstraction pretending it isn't
As soon as your repository returns a Builder, or your controller calls $course->enrollments()->where(...) on a returned model, your code still depends entirely on Eloquent. Returning Eloquent models is a perfectly pragmatic choice. Just be honest that you've given up the "swappable data source" benefit, and justify the repository with one of the other reasons.
3. The repository as a dumping ground for business logic
An enroll() method that saves an enrollment, sends an email and creates an invoice? That's no longer a repository, it's a service class with the wrong name. Keep repositories about retrieving and storing. Behaviour belongs in actions or services that use the repository.
Recap
- A repository is a boundary between your application and your data storage, described in domain language.
- Try a scope first. It solves most duplication with less code.
- The real payoff is swapping and stacking, as in the caching decorator. Testability alone is a weak argument in Laravel.
- Avoid generic CRUD repositories and business logic inside your repository.
- Choose deliberately: for a small project with one team, Eloquent without an extra layer is often the better architecture.
Where to go next
Business logic doesn't belong in your repository, so where does it go? In Action classes: where your business logic belongs you'll build on this repository with an EnrollStudent action.
Not sure whether the layers in your own codebase are paying for themselves, or does your repository layer mostly generate maintenance? That's exactly the kind of question a codebase audit answers: an independent look at where your architecture helps you and where it slows you down.