PHP.nl
Best Practices

SOLID-principes in PHP: een praktische gids

P
PHP.nl Redactie
📖 7 min leestijd 👁 365

SOLID is een verzameling van vijf ontwerpprincipes die Robert C. Martin begin deze eeuw bundelde, en die Michael Feathers het acroniem gaf. Ze gaan niet over syntax of frameworks, maar over één vraag: hoe schrijf je code die je over drie jaar nog durft aan te passen? In deze gids lopen we elk principe door met een realistisch PHP-voorbeeld, en we zijn eerlijk over wanneer je ze beter niet te strikt toepast.

S: Single Responsibility Principle

Een klasse heeft één reden om te veranderen.

Het woord "verantwoordelijkheid" leidt vaak tot de misvatting dat een klasse maar één methode mag hebben. Dat is niet de bedoeling. Het gaat erom wie of wat een wijziging veroorzaakt. Een klasse die zowel een factuur berekent als hem als PDF opmaakt, verandert als de btw-regels veranderen, als marketing een nieuw logo wil én als de factuur voortaan via een andere mailer moet. Drie redenen, dus drie klassen.

// Voor: rekenen, opmaken en versturen in één klasse
final class FactuurService
{
    public function maak(Bestelling $bestelling): string
    {
        $totaal = 0;
        foreach ($bestelling->regels as $regel) {
            $totaal += $regel->prijs * $regel->aantal;
        }
        $btw = $totaal * 0.21;

        $html = "<h1>Factuur {$bestelling->nummer}</h1>...";
        $dompdf = new Dompdf();
        $dompdf->loadHtml($html);
        $dompdf->render();

        mail($bestelling->klant->email, 'Je factuur', '...');

        return $dompdf->output();
    }
}
// Na: elke klasse verandert om één reden
final class FactuurCalculator
{
    public function bereken(Bestelling $bestelling): Factuur { /* ... */ }
}

final class FactuurPdf
{
    public function render(Factuur $factuur): string { /* ... */ }
}

final class FactuurVerzender
{
    public function __construct(private Mailer $mailer) {}

    public function verstuur(Factuur $factuur, string $pdf): void { /* ... */ }
}

Een praktische check: kun je in één zin zeggen wat de klasse doet, zonder het woord "en"? Zo niet, dan doet hij waarschijnlijk te veel.

O: Open/Closed Principle

Code is open voor uitbreiding, maar gesloten voor wijziging.

Je herkent een overtreding aan een switch of match die bij elke nieuwe variant groter wordt, verspreid over meerdere plekken in de codebase:

// Elke nieuwe betaalmethode betekent deze functie openbreken
function verwerkBetaling(string $methode, int $bedragInCenten): void
{
    match ($methode) {
        'ideal' => (new MollieClient())->ideal($bedragInCenten),
        'creditcard' => (new StripeClient())->charge($bedragInCenten),
        'factuur' => (new FactuurService())->maakOpenstaand($bedragInCenten),
    };
}

Met een interface voeg je een nieuwe betaalmethode toe door een klasse te schrijven, zonder bestaande code aan te raken:

interface Betaalmethode
{
    public function ondersteunt(string $methode): bool;

    public function verwerk(int $bedragInCenten): void;
}

final class BetalingVerwerker
{
    /** @param iterable<Betaalmethode> $methoden */
    public function __construct(private iterable $methoden) {}

    public function verwerk(string $methode, int $bedragInCenten): void
    {
        foreach ($this->methoden as $kandidaat) {
            if ($kandidaat->ondersteunt($methode)) {
                $kandidaat->verwerk($bedragInCenten);

                return;
            }
        }

        throw new OnbekendeBetaalmethode($methode);
    }
}

In Laravel registreer je de implementaties met $this->app->tag(), in Symfony met een tagged iterator. Het framework levert ze dan automatisch aan de verwerker.

L: Liskov Substitution Principle

Een subklasse moet overal bruikbaar zijn waar de parent verwacht wordt, zonder dat het gedrag verrast.

Dit is het principe dat het vaakst ongemerkt wordt geschonden. Het klassieke voorbeeld is een subklasse die een methode "uitzet":

class Bestand
{
    public function schrijf(string $inhoud): void { /* ... */ }
}

final class AlleenLezenBestand extends Bestand
{
    public function schrijf(string $inhoud): void
    {
        throw new LogicException('Dit bestand is alleen-lezen');
    }
}

Elke functie die een Bestand accepteert, gaat ervan uit dat schrijf() werkt. Met een AlleenLezenBestand breekt die aanname. De oplossing is de hiërarchie omdraaien: een interface Leesbaar en een interface Schrijfbaar, en alleen klassen die echt kunnen schrijven implementeren de tweede.

PHP helpt je hier een eind op weg. De taal dwingt af dat parametertypes in een subklasse breder mogen worden (contravariant) en returntypes smaller (covariant), maar niet andersom. Wat PHP niet kan afdwingen is gedrag: een strengere validatie, een andere exceptie of een stille no-op. Daar heb je tests voor nodig, het liefst een gedeelde testsuite die je tegen elke implementatie draait.

I: Interface Segregation Principle

Geen klasse zou afhankelijk moeten zijn van methoden die hij niet gebruikt.

Grote "god-interfaces" dwingen implementaties om lege methoden te schrijven, en maken elke wijziging aan de interface duur:

interface GebruikerRepository
{
    public function vind(int $id): ?Gebruiker;
    public function zoekOpEmail(string $email): ?Gebruiker;
    public function bewaar(Gebruiker $gebruiker): void;
    public function verwijder(Gebruiker $gebruiker): void;
    public function exporteerNaarCsv(): string;
    public function statistiekenPerMaand(): array;
}

Een loginservice heeft alleen zoekOpEmail() nodig, een rapportage alleen de statistieken. Splits de interface op naar gebruik, niet naar entiteit:

interface VindtGebruikerOpEmail
{
    public function zoekOpEmail(string $email): ?Gebruiker;
}

interface BewaartGebruikers
{
    public function bewaar(Gebruiker $gebruiker): void;
}

Eén concrete repository mag gerust meerdere interfaces implementeren. Het verschil is dat de loginservice nu alleen afhankelijk is van wat hij echt gebruikt, en in een test met een simpele fake toe kan.

D: Dependency Inversion Principle

Hoog-niveau code hangt niet af van laag-niveau details; beide hangen af van abstracties.

In de praktijk: je businesslogica roept geen new GuzzleClient(), DB::table() of Http::post() aan, maar krijgt een interface binnen via de constructor.

interface Wisselkoersen
{
    public function koers(string $van, string $naar): float;
}

final class PrijsOmrekenaar
{
    public function __construct(private Wisselkoersen $koersen) {}

    public function naarDollar(int $centen): int
    {
        return (int) round($centen * $this->koersen->koers('EUR', 'USD'));
    }
}

De concrete implementatie, bijvoorbeeld een klasse die de ECB-API aanroept, koppel je in de service container. In tests geef je een vaste koers mee. De PrijsOmrekenaar weet niet en hoeft niet te weten waar de koers vandaan komt.

Let op het verschil tussen dependency inversion (een ontwerpprincipe) en dependency injection (een techniek). Je kunt een concrete klasse injecteren en het principe nog steeds schenden. Het gaat erom dat de abstractie bij de hoog-niveau code hoort: de Wisselkoersen-interface wordt gedefinieerd door wat de omrekenaar nodig heeft, niet door wat de ECB-API toevallig aanbiedt.

Wanneer je SOLID niet te strikt moet nemen

SOLID is een set heuristieken, geen wetboek. Een paar situaties waarin we bewust pragmatisch zijn:

  • Eén implementatie, en dat blijft zo. Een interface voor een klasse die nooit een tweede implementatie krijgt en die je in tests niet hoeft te vervangen, is alleen extra indirectie.
  • Kleine scripts en prototypes. Wie een eenmalig importscript in vijf klassen opsplitst, heeft het principe begrepen maar het doel gemist.
  • Framework-conventies. Een Laravel-controller die direct Eloquent gebruikt, schendt strikt genomen dependency inversion. Voor een CRUD-scherm is dat prima. Pas als er echte businesslogica bijkomt, loont het om die eruit te trekken.

De vraag is steeds: maakt deze abstractie een waarschijnlijke toekomstige wijziging goedkoper? Zo ja, dan is hij het waard.

SOLID in een bestaande codebase

De meeste teams beginnen niet met een schone lei. Een paar tips om SOLID stapsgewijs in een oudere applicatie te brengen:

  1. Begin waar de pijn zit. De klassen die het vaakst veranderen (zie git log --format= --name-only | sort | uniq -c | sort -rn) en de meeste bugs opleveren, leveren het meeste op.
  2. Schrijf eerst karakteriseringstests die het huidige gedrag vastleggen, ook als dat gedrag raar is. Pas daarna refactor je.
  3. Extract, don't rewrite. Trek een stuk logica in een nieuwe klasse en laat de oude klasse delegeren. Kleine stappen, elke stap deploybaar.
  4. Laat tooling meekijken. PHPStan op een hoger niveau en Rector voor mechanische refactorings halen veel handwerk weg.

Zit je met een applicatie waar elke wijziging een risico is geworden? Lees dan hoe je een legacy PHP-applicatie stap voor stap moderniseert zonder alles opnieuw te bouwen. In onze documentatie lees je meer over traits, een manier om gedrag te delen zonder diepe overervingsbomen. En het artikel over PSR-standaarden laat zien hoe het hele PHP-ecosysteem op dezelfde gedachte leunt: programmeer tegen een interface, niet tegen een implementatie.