Alle artikelen

Waarom ik repositories gebruik naast Eloquent

Met repositories naast Eloquent geef ik datatoegang een vaste plek, kan ik applicatielogica afzonderlijk testen en behoud ik concrete modeltypes voor statische controle en autocomplete.

7 min leestijd

Een repository boven Eloquent roept al snel discussie op. Laravel kan zelf modellen ophalen, relaties laden en gegevens opslaan. Wat voegt een extra klasse daar dan aan toe? Voor mij zijn dat beter onderhoudbare datatoegang, eenvoudiger testen en duidelijke typering.

Ik wil gedeelde queries op één plek aanpassen en applicatielogica met vooraf bepaalde gegevens kunnen testen, zonder database. Daarnaast hecht ik belang aan objectgeoriënteerd programmeren (OOP) met duidelijke verantwoordelijkheden en expliciete types. Herkenning van het concrete modeltype helpt bij autocomplete en laat PHPStan verkeerd typegebruik signaleren voordat de code wordt uitgevoerd.

Daarvoor geef ik iedere repository een Eloquent-model waarmee zij nieuwe queries begint. PHPStan-generics behouden het modeltype, terwijl Eloquent de querymethoden blijft leveren. Iedere modelsoort krijgt een eigen repository, die ik via dependency injection aan de aanroeper meegeef.

Datatoegang op één plek kunnen aanpassen

Stel dat een webpagina en een API hetzelfde productoverzicht aanbieden: gepubliceerde producten, in dezelfde volgorde en met dezelfde limiet. Als beide de query zelf opbouwen, vraagt een wijziging om meerdere aanpassingen.

Ik geef die gedeelde ophaalstap liever een eigen methode, zoals publishedForOverview(). De aanroep vertelt waarvoor de gegevens nodig zijn; de repository beschrijft hoe ze worden opgehaald. Als beide overzichten een productcategorie tonen, kan ik daar eager loading toevoegen. Alle gebruikers van die methode krijgen de aangepaste query.

De repository maakt de applicatie niet vanzelf sneller. Het voordeel is een gedeelde plek voor optimalisaties, waarvan je het effect nog steeds moet controleren.

Het bezwaar tegen een extra CRUD-laag

Philip Rehberger beschrijft in een artikel uit 2026 de kritiek op repositories die vooral bestaande Eloquent-methoden verpakken. Hij wijst op extra onderhoud en op mogelijkheden binnen Eloquent, waaronder scopes en eigen querybuilders. Dat bezwaar vind ik terecht: een extra laag moet iets opleveren voor de applicatie.

Ik voeg daarom methoden toe voor applicatiespecifieke querylogica en één gedeelde methode om een query te beginnen. Bestaande Eloquent-methoden gebruik ik rechtstreeks op de builder die daaruit komt.

Deze aanpak gebruikt compositie: de repository bevat een model en laat dat de builder maken. Zoals Refactoring.Guru design patterns beschrijft, zijn patterns ontwerpideeën die je aan een concreet probleem kunt aanpassen. Hier wil ik datatoegang organiseren en Eloquent blijven benutten.

Compositie met behoud van het modeltype

Het volgende uitlegvoorbeeld gebruikt PHP 8.3, Laravel 13, PHPStan en Larastan 3.12. Alle repositories delen één generieke interface en één basisklasse. Samen leggen die de relatie met Eloquent vast:

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

/**
 * @template TModel of Model
 */
interface RepositoryInterface
{
    /** @return Builder<TModel> */
    public function query(): Builder;
}

/**
 * @template TModel of Model
 * @implements RepositoryInterface<TModel>
 */
abstract class EloquentRepository implements RepositoryInterface
{
    /** @param TModel $model */
    public function __construct(protected readonly Model $model) {}

    /** @return Builder<TModel> */
    public function query(): Builder
    {
        return $this->model->newQuery();
    }
}

Met de gedeelde interface wil ik modelgebonden type-informatie beschikbaar maken voor ontwikkeltools. TModel staat bij de productrepository voor Product. De generieke annotaties verbinden dat type aan de repository en het resultaat van query(): een Builder<Product>.

De constructor bewaart het model, maar maakt nog geen query. Iedere aanroep van query() laat Eloquent zelf de builder opbouwen, inclusief global scopes, standaard eager loading en de eventuele eigen builder van het model. Die initialisatie staat in de Eloquent Model-broncode.

De concrete repository vraagt vervolgens het juiste model in haar constructor en voegt de overzichtsmethode toe:

use App\Models\Product;
use Illuminate\Database\Eloquent\Collection;

/** @extends EloquentRepository<Product> */
class ProductRepository extends EloquentRepository
{
    public function __construct(Product $model)
    {
        parent::__construct($model);
    }

    /** @return Collection<int, Product> */
    public function publishedForOverview(): Collection
    {
        return $this->query()
            ->where('published', true)
            ->orderBy('id')
            ->limit(20)
            ->get();
    }
}

PHPStan herkent query()->find(1) als Product|null, ook via RepositoryInterface<Product>. De eigen overzichtsmethode levert een collectie producten.

Daarbij gebruik ik Larastan, dat PHPStan aanvult met kennis van Laravel en Eloquent. Laravel IDE Helper genereert aanvullende PHPDocs voor onder meer modelvelden en relaties, zodat de IDE die bij autocomplete kan aanbieden.

In de controle met Intelephense 1.18.5 en rechtstreeks op het model gegenereerde PHPDocs werkte typeherkenning en autocomplete via zowel de concrete repository als de interface. Dat resultaat geldt voor die geteste combinatie; ondersteuning in andere IDE's moet afzonderlijk worden gecontroleerd.

De native typehint Product $model stuurt de dependency injection: Laravel gebruikt die om een Product-instantie te leveren. De generics dienen de ontwikkeltools en vertellen Laravel niet welk model het moet aanmaken. Iedere andere modelsoort krijgt op dezelfde manier een concrete repository met het passende constructortype.

De juiste repository automatisch injecteren

Een controller ontvangt de concrete repository via zijn constructor en gebruikt de overzichtsmethode:

class ProductController
{
    public function __construct(
        private readonly ProductRepository $products,
    ) {}

    /** @return Collection<int, Product> */
    public function index(): Collection
    {
        return $this->products->publishedForOverview();
    }
}

Laravel resolveert achtereenvolgens ProductController, ProductRepository en Product via automatische resolutie van concrete klassen, zonder expliciete bindings. Ook de API-controller kan deze repository ontvangen en dezelfde overzichtsmethode gebruiken.

Dezelfde repository kan meerdere zoekacties uitvoeren. Iedere overzichtsaanroep begint via query() met een nieuwe builder, zodat eerdere voorwaarden niet doorwerken. Voor rechtstreeks Eloquent-gebruik is dat beginpunt zichtbaar:

$this->products->query()->find(1);
$this->products->query()->find(2);

De extra query() is een bewuste afweging: ik hoef geen Eloquent-methoden na te bouwen en de querylevensduur is expliciet. Bewaar je de teruggegeven builder zelf in een variabele, dan blijven voorwaarden op die builder wel staan.

Het voorbeeld veronderstelt een productmodel met een veld published en een category-relatie. Als beide overzichten die categorie nodig hebben, voeg ik eager loading toe aan de centrale methode:

return $this->query()
    ->with('category')
    ->where('published', true)
    ->orderBy('id')
    ->limit(20)
    ->get();

Eager loading kan losse relatiequeries tijdens het verwerken voorkomen. Het expliciete with() legt vast dat de categorie bij dit overzicht hoort. Een onnodige relatie vooraf laden kan juist extra werk veroorzaken.

Omdat query() de builder beschikbaar maakt, kan een controller zelf voorwaarden toevoegen. Gedeelde querylogica in de repository houden blijft daarmee ook een werkafspraak.

Applicatielogica en queries afzonderlijk testen

Stel dat een service een melding maakt wanneer het productoverzicht leeg is. Via de constructor geef ik een testdouble mee die voor publishedForOverview() een lege collectie of vooraf opgebouwde producten teruggeeft. Zo test ik de reactie op die gegevens zonder de queryketen na te bootsen.

Omdat de aanroeper de concrete ProductRepository verwacht, kan een subklasse als testdouble dienen: die vervangt de overzichtsmethode en slaat de echte constructor over. De repository is daarom niet final. Alleen de gedeelde interface implementeren voldoet niet aan het concrete constructortype.

Deze test blijft zonder database werken zolang de geteste code geen andere queries uitvoert, relaties bijlaadt of modellen opslaat. De testdouble vervangt alleen de overzichtsmethode. Verandert de echte query maar blijft de afgesproken uitkomst gelijk, dan kan de servicetest blijven staan.

De repository krijgt afzonderlijk tests tegen een testdatabase voor de selectie, volgorde, limiet, global scopes en geladen relaties. Ook de injectie van het juiste model verdient een controle. Tests van volledige aanvragen blijven nuttig om de samenwerking tussen onderdelen te controleren.

Waarom ik de Eloquent-afhankelijkheid accepteer

De repository bevat een Eloquent-model en levert Eloquent-builders, modellen en collecties. Een andere ORM invoeren zou daarom ook aanpassingen buiten de concrete repository vragen. In de projecten uit mijn ervaring is zo'n vervanging niet aan de orde geweest. Die koppeling accepteer ik daarom bewust.

Voor losse herbruikbare queryvoorwaarden zijn Laravel-scopes eveneens bruikbaar. Ik kies voor deze repositorystructuur wanneer meerdere aanroepers samenhangende ophaalmethoden nodig hebben die ik gericht wil aanpassen en testen. Dat rechtvaardigt voor mij de extra klasse, met behoud van Eloquent en zijn modeltypes.

Terug naar alle artikelen