Het probleem
Het repository pattern is een van de meest besproken onderwerpen in de Laravel-community. De ene helft noemt het onmisbaar voor "echte" architectuur, de andere helft noemt het overbodige ceremonie bovenop Eloquent. Beide kampen hebben een punt. Deze post laat zien wat een repository concreet oplost, wat het kost, en wanneer die extra laag zich terugbetaalt.
Wat je leert
- Wat een repository is, en wat het níet is
- Welke lichtere alternatieven Laravel al biedt voordat je een repository nodig hebt
- Hoe je een repository bouwt met een interface en een binding in de service container
- Een praktijkvoorbeeld waar de abstractie zich terugbetaalt: een caching-decorator
- Een eerlijke afweging van de voor- en nadelen
Voorkennis
- Je werkt comfortabel met Eloquent, inclusief relaties en query scopes
- Je weet wat dependency injection is en hoe de service container interfaces aan implementaties koppelt (zie Service providers en de service container)
Wat een repository is
Een repository is een laag tussen je applicatie en je data-opslag. Je code vraagt om "de aankomende cursussen" of "de cursus met deze slug", zonder te weten hoe of waar die data wordt opgehaald. Het idee komt uit Domain-Driven Design, waar je domeincode niets mag weten van de database.
De spanning in Laravel is dat Eloquent een Active Record-implementatie is. Een model is al een toegangspunt tot je data, met een query builder, relaties en scopes. Een repository die alleen Course::find() doorgeeft, voegt dus niets toe. Een repository verdient zijn plek pas wanneer het iets doet wat Eloquent niet voor je doet:
- Een grens trekken: je controllers en services weten niet dat Eloquent bestaat, wat helpt bij grote teams of modulaire domeinen.
- Implementaties verwisselen of stapelen: een andere databron, of gedrag zoals caching of logging, zonder je aanroepende code aan te passen.
- Complexe, herbruikte queries centraliseren: één plek voor query-logica die op tien plekken nodig is en niet in een scope past.
Testbaarheid wordt vaak als hoofdargument genoemd, maar in Laravel is dat zwakker dan het klinkt. Met RefreshDatabase en een SQLite-database in het geheugen test je Eloquent-code al snel en realistisch.
Stap voor stap
Stap 1 — Het startpunt
Een typische controller met query-logica erin:
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'));
}
}
Op zich prima. Het wordt een probleem wanneer dezelfde query ook in je API-controller, een scheduled command en een Livewire-component opduikt, elke keer net iets anders.
Stap 2 — Probeer eerst een scope
Voordat je een repository bouwt: veel duplicatie los je al op met een local scope op het model.
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();
Is dit alles wat je nodig hebt? Stop dan hier. Een scope is minder code, blijft combineerbaar, en iedere Laravel-developer begrijpt hem direct.
Stap 3 — Definieer de interface
Heb je wél een grens nodig, begin dan met een contract dat beschrijft wat je applicatie nodig heeft, in domeintaal:
namespace App\Repositories;
use App\Models\Course;
use Illuminate\Support\Collection;
interface CourseRepository
{
public function upcoming(): Collection;
public function findBySlug(string $slug): ?Course;
}
Let op de methodenamen. upcoming() beschrijft een vraag uit je domein, geen databasebewerking.
Stap 4 — Bouw de Eloquent-implementatie
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();
}
}
De repository hergebruikt de scope uit stap 2. Scopes en repositories sluiten elkaar niet uit.
Stap 5 — Koppel de interface in de 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);
}
Stap 6 — Injecteer de 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(),
]);
}
}
Tot hier heb je vooral code verplaatst. De echte winst komt in de volgende stap.
Stap 7 — Waar het zich terugbetaalt: een caching-decorator
De cursuslijst wordt op elke pagina getoond en verandert zelden. Je wilt caching toevoegen zonder één controller aan te raken. Omdat alles via de interface loopt, wikkel je de bestaande implementatie simpelweg in:
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);
}
}
Pas de binding aan:
public function register(): void
{
$this->app->bind(CourseRepository::class, fn ($app) => new CachedCourseRepository(
$app->make(EloquentCourseRepository::class)
));
}
Elke plek die CourseRepository gebruikt, is nu gecachet. Controllers, commands en tests merken er niets van. Hetzelfde principe werkt voor logging, metrics, of een overstap naar een externe API als databron.
Stap 8 — De eerlijke afweging
Een repository is de moeite waard als:
- dezelfde query-logica op veel plekken nodig is en niet netjes in een scope past;
- je gedrag rond data wilt stapelen, zoals caching, logging of een fallback-bron;
- data (deels) uit een andere bron komt dan je database;
- je in een groot team of modulaire codebase een harde grens wilt tussen domein en opslag.
Sla het over als:
- je repository vooral
find(),all()encreate()doorgeeft aan Eloquent; - je project klein is en één team heeft dat Eloquent goed kent;
- het hoofdargument "misschien stappen we ooit over op een andere database" is. Dat gebeurt zelden, en als het gebeurt, is je repository-laag zelden het probleem.
Veelgemaakte fouten
1. Een generieke 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);
}
Dit is een slechtere versie van Eloquent. Je verliest eager loading, pagination en scopes, of je bouwt ze alsnog na met parameters als find($id, $with = [], $columns = ['*']). Een repository hoort methodes te hebben die jouw domein beschrijft (upcoming(), withOpenSeats()), geen generieke CRUD.
2. Een lekkende abstractie die doet alsof hij dat niet is
Zodra je repository een Builder teruggeeft, of je controller $course->enrollments()->where(...) aanroept op een geretourneerd model, is je code nog steeds volledig afhankelijk van Eloquent. Eloquent-models teruggeven is een prima pragmatische keuze. Wees alleen eerlijk dat je daarmee de "verwisselbare databron" hebt opgegeven, en onderbouw de repository met een van de andere redenen.
3. De repository als stortplaats voor business-logica
enroll() die een inschrijving opslaat, een mail verstuurt en een factuur aanmaakt? Dat is geen repository meer, dat is een service-klasse met een verkeerde naam. Houd repositories bij ophalen en opslaan. Gedrag hoort thuis in actions of services die de repository gebruiken.
Samenvatting
- Een repository is een grens tussen je applicatie en je data-opslag, beschreven in domeintaal.
- Probeer eerst een scope. Die lost de meeste duplicatie op met minder code.
- De echte winst zit in verwisselen en stapelen, zoals de caching-decorator. Testbaarheid alleen is in Laravel een zwak argument.
- Vermijd generieke CRUD-repositories en business-logica in je repository.
- Kies bewust: voor een klein project met één team is Eloquent zonder extra laag vaak de betere architectuur.
Hoe verder?
Business-logica hoort niet in je repository, maar waar dan wel? In Action-klassen: waar je business-logica thuishoort bouw je verder op deze repository met een EnrollStudent-action.
Twijfel je of de lagen in je eigen codebase zich terugbetalen, of lijkt je repository-laag vooral onderhoud op te leveren? Precies dat soort vragen komt aan bod in een codebase audit: een onafhankelijke blik op waar je architectuur je helpt en waar hij je vertraagt.