Gå til hovedindhold

Laravel PHP Webudvikling

SOLID-principper i PHP: Fra teori til kode du ikke fortryder

11 min læsning Daniel S. Nielsen
SOLID-principper i PHP: Fra teori til kode du ikke fortryder

Teknisk gæld opstår sjældent fordi nogen bevidst valgte en dårlig løsning. Den opstår fordi en klasse voksede sig lidt for stor, en metode fik lidt for mange ansvarsområder, og ingen nogensinde tog sig tid til at stoppe op. SOLID-principperne er ikke akademisk sniksnak - de er det nærmeste vi kommer et sæt praktiske regler for hvornår kode begynder at rådne indefra.

De fleste PHP-udviklere kender forkortelsen. Færre bruger principperne aktivt i daglig kodepraksis. Denne artikel er ikke endnu en gennemgang af definitionerne - det er en gennemgang af hvad principperne faktisk kræver af din kode, illustreret med eksempler der ligner de ting du møder i virkelige projekter.

Single Responsibility Principle - én klasse, ét job

SRP siger at en klasse kun bør have én grund til at ændre sig. Det lyder simpelt. I praksis er det det princip der oftest brydes, fordi det er nemt at tilføje lidt ekstra logik til en klasse der allerede er åben foran dig.

Betragt denne typiske klasse fra et mellemstort projekt:

// BEFORE
class Order {
    public function calculateTotal(): float {
        // beregn totalpris med rabatter
    }

    public function saveToDatabase(): void {
        // gem ordre i databasen med SQL
    }

    public function sendConfirmationEmail(): void {
        // byg og afsend bekræftelsesmail
    }

    public function generatePdfInvoice(): void {
        // generér PDF-faktura
    }
}

Order-klassen her har fire grunde til at ændre sig: forretningslogik for priser, databasestruktur, e-mail-skabeloner og PDF-generering. Når du ændrer dit e-mailsetup, rører du ved ordrelogikken. Når databaseskemaet ændres, åbner du den samme fil som forretningsreglerne bor i.

// AFTER
class Order {
    public function calculateTotal(): float {
        // kun forretningslogik for prisen
    }
}

class OrderRepository {
    public function save(Order $order): void {
        // databaselogik isoleret her
    }
}

class OrderMailer {
    public function sendConfirmation(Order $order): void {
        // e-maillogik isoleret her
    }
}

class InvoiceGenerator {
    public function generatePdf(Order $order): void {
        // PDF-logik isoleret her
    }
}

Nu har hver klasse præcis én grund til at ændre sig. Det gør det ikke kun nemmere at finde fejl - det gør det muligt at teste klasserne isoleret og genbruge dem på tværs af kontekster.

Open/Closed Principle - åben for udvidelse, lukket for ændring

OCP handler om at designe kode der kan udvides uden at blive ændret. Det er særligt relevant når du tilføjer ny funktionalitet til et system der allerede er i produktion.

// BEFORE
class DiscountCalculator {
    public function calculate(string $type, float $price): float {
        if ($type === 'student') {
            return $price * 0.8;
        } elseif ($type === 'senior') {
            return $price * 0.75;
        } elseif ($type === 'employee') {
            return $price * 0.6;
        }
        return $price;
    }
}

Hver gang en ny rabattype skal tilføjes, åbner du DiscountCalculator og ændrer i den eksisterende metode. Det bryder OCP og introducerer risiko for at ødelægge eksisterende rabattyper.

// AFTER
interface Discount {
    public function apply(float $price): float;
}

class StudentDiscount implements Discount {
    public function apply(float $price): float {
        return $price * 0.8;
    }
}

class SeniorDiscount implements Discount {
    public function apply(float $price): float {
        return $price * 0.75;
    }
}

class EmployeeDiscount implements Discount {
    public function apply(float $price): float {
        return $price * 0.6;
    }
}

class DiscountCalculator {
    public function calculate(Discount $discount, float $price): float {
        return $discount->apply($price);
    }
}

Nu tilføjer du en ny rabattype ved at oprette en ny klasse - uden at røre den eksisterende kode. DiscountCalculator er lukket for ændring, men åben for udvidelse via interfacet.

Liskov Substitution Principle - det mest misforståede princip

LSP siger at et objekt af en underklasse altid skal kunne bruges i stedet for et objekt af superklassen - uden at programmets korrekthed brydes. Det lyder som en selvfølge, men det brydes overraskende ofte.

Den klassiske fejl ser sådan her ud:

// BEFORE
class Rectangle {
    protected int $width;
    protected int $height;

    public function setWidth(int $width): void {
        $this->width = $width;
    }

    public function setHeight(int $height): void {
        $this->height = $height;
    }

    public function area(): int {
        return $this->width * $this->height;
    }
}

class Square extends Rectangle {
    public function setWidth(int $width): void {
        $this->width = $width;
        $this->height = $width; // tvinger kvadrat-invariant
    }

    public function setHeight(int $height): void {
        $this->width = $height;
        $this->height = $height;
    }
}

Matematisk set er et kvadrat et rektangel. Men i kode er det en fælde. Hvis du har en funktion der forventer et Rectangle og sætter bredde og højde uafhængigt, vil en Square opføre sig uventet - fordi den overskriver begge dimensioner. Substitutionen bryder programmets forventning.

// AFTER
interface Shape {
    public function area(): int;
}

class Rectangle implements Shape {
    public function __construct(
        private int $width,
        private int $height
    ) {}

    public function area(): int {
        return $this->width * $this->height;
    }
}

class Square implements Shape {
    public function __construct(
        private int $side
    ) {}

    public function area(): int {
        return $this->side ** 2;
    }
}

Her arver Square ikke fra Rectangle - begge implementerer et fælles interface. Arv bruges kun når underklassen virkelig kan erstatte superklassen uden bivirkninger. Når det ikke er tilfældet, er komposition eller interfaces det rigtige valg.

Interface Segregation Principle - interfaces der passer til dem der bruger dem

ISP handler om at undgå interfaces der er for brede. Når en klasse tvinges til at implementere metoder den ikke bruger, er det et tegn på at interfacet er designet forkert.

// BEFORE
interface Worker {
    public function work(): void;
    public function eat(): void;
    public function sleep(): void;
}

class HumanWorker implements Worker {
    public function work(): void { /* ... */ }
    public function eat(): void { /* ... */ }
    public function sleep(): void { /* ... */ }
}

class RobotWorker implements Worker {
    public function work(): void { /* ... */ }
    public function eat(): void {
        throw new \Exception("Robotter spiser ikke");
    }
    public function sleep(): void {
        throw new \Exception("Robotter sover ikke");
    }
}

RobotWorker er tvunget til at implementere metoder der ikke giver mening for den. Det er et interface der er designet ud fra én specifik bruger - mennesket - og derefter påtvunget alle andre.

// AFTER
interface Workable {
    public function work(): void;
}

interface Feedable {
    public function eat(): void;
}

interface Restable {
    public function sleep(): void;
}

class HumanWorker implements Workable, Feedable, Restable {
    public function work(): void { /* ... */ }
    public function eat(): void { /* ... */ }
    public function sleep(): void { /* ... */ }
}

class RobotWorker implements Workable {
    public function work(): void { /* ... */ }
}

Små, fokuserede interfaces er langt nemmere at komponere og implementere korrekt. Klasser implementerer kun det de faktisk kan og skal.

Dependency Inversion Principle - afhæng af abstraktioner, ikke implementationer

DIP er det princip der binder de andre sammen i praksis. Det siger at højniveaumoduler ikke bør afhænge direkte af lavniveaumoduler - begge bør afhænge af abstraktioner. I PHP-sammenhæng betyder det: injicér afhængigheder via interfaces, ikke via konkrete klasser.

// BEFORE
class ReportGenerator {
    public function generate(): void {
        $mailer = new SmtpMailer(); // direkte afhængighed af konkret klasse
        $mailer->send('rapport@eksempel.dk', 'Her er din rapport');
    }
}

Her er ReportGenerator hårdt koblet til SmtpMailer. Vil du teste klassen, sender den rigtige mails. Vil du skifte til en anden maildriver, skal du ændre i ReportGenerator.

// AFTER
interface Mailer {
    public function send(string $to, string $message): void;
}

class SmtpMailer implements Mailer {
    public function send(string $to, string $message): void {
        // SMTP-implementering
    }
}

class LogMailer implements Mailer {
    public function send(string $to, string $message): void {
        // logger til fil i stedet - nyttigt i test og staging
    }
}

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

    public function generate(): void {
        $this->mailer->send('rapport@eksempel.dk', 'Her er din rapport');
    }
}

Nu kan du i tests injicere en LogMailer eller en mock. I produktion injicerer du SmtpMailer. ReportGenerator ved intet om implementeringsdetaljerne - den kender kun kontrakten.

Laravels service container er i praksis en implementering af dette princip. Når du binder et interface til en konkret klasse i en service provider, er det præcis DIP i aktion:

// I en Laravel Service Provider
$this->app->bind(Mailer::class, SmtpMailer::class);

// I en anden kontekst - fx testing eller staging
$this->app->bind(Mailer::class, LogMailer::class);

Det er én af grundene til at Laravel-projekter der er bygget med dependency injection fra starten er langt nemmere at vedligeholde og teste end projekter der er vokset organisk uden arkitekturmæssig bevidsthed. Containeren gør det let at leve efter DIP - men princippet skal stadig aktivt vælges til.

Hvornår SOLID er en guide og ikke en lov

SOLID er ikke en facitliste. Det er et sæt spørgsmål du stiller til din kode, og svarene afhænger altid af kontekst.

Et lille script der kører én gang og aldrig vedligeholdes, behøver ikke et interface-hierarki. En prototype der skal afklare om en idé virker, bør ikke bruge tid på at separere ansvarsområder ned til mindste detalje. Og der er situationer - særligt i meget simple domæner - hvor OCP-overholdelse kræver flere abstraktionslag end problemet fortjener.

Tommelfingerreglen er denne: SOLID-principperne betaler sig hjem i den samme valuta som teknisk gæld koster dig - tid og vedligeholdelse. Jo oftere kode ændres, jo større er gevinsten ved at overholde principperne. En klasse der aldrig ændres, bærer ingen rente på sin tekniske gæld. En klasse der ændres ugentligt og er koblet til alt muligt andet, er en konstant kilde til fejl og frustration.

Brug SRP aktivt fra dag ét - det er det princip der giver hurtigst afkast og kræver mindst arkitekturdesign. Brug OCP og DIP når du bygger systemer der skal vokse eller udskiftes over tid. Brug LSP som en løbende kontrol af dit arvehierarki. Og brug ISP som en påmindelse om at interfaces er kontrakter - de bør designes ud fra dem der bruger dem, ikke dem der implementerer dem.

Kode du ikke fortryder er sjældent kode der er perfekt fra starten. Det er kode der er skrevet med tilstrækkelig bevidsthed om at den skal ændres - og som er struktureret til at klare det uden at tage alt andet med sig i faldet. Det er præcis det SOLID-principperne hjælper dig med.

Tilbage til artikler

Relateret ydelse

Laravel- og PHP-udvikling

Webapps, API'er og backend-systemer bygget fra bunden. Du ejer koden.

Se ydelsen

Læs videre

Relaterede artikler

Næste skridt

Har du spørgsmål til artiklen?

Kontakt mig hvis du vil vide mere om emnet.

  • Uforpligtende og gratis
  • Du taler direkte med mig
  • Svar som regel inden for 24 timer
Få et fast tilbud Ring til Daniel på 42 50 88 11 Skriv til info@kodesmeden.com