Back to blog

Architecture

SOLID Principles in PHP: Practical Examples for Better Code Quality

Demonstrates each SOLID principle with before/after PHP refactoring examples, showing how they reduce coupling and increase cohesion.

  • PHP
  • SOLID
  • Architecture
  • Refactoring
  • OOP

SEO Metadata

SEO Title Options

  1. SOLID Principles in PHP: Practical Examples for Better
  2. SOLID Principles in PHP: Practical: Practical 2026 Guide
  3. Architecture Playbook: SOLID Principles in PHP: Practical

Meta Description Options

  1. Learn SOLID Principles in PHP: Practical Examples for Better Code Quality with a practical Architecture framework, expert mistakes, implementation steps.
  2. Demonstrates each SOLID principle with before/after PHP refactoring examples, showing how they reduce coupling and increase cohesion.

URL Slug

solid-principles-php-practical-examples-better-code-quality

Focus Keyword

SOLID Principles in PHP: Practical Examples for Better Code Quality

Additional LSI Keywords

  • Architecture
  • PHP
  • SOLID
  • Refactoring
  • OOP
  • SOLID Principles in PHP: Practical Examples for Better Code Quality
  • production checklist
  • implementation guide
  • best practices
  • architecture decisions
  • testing strategy
  • performance impact

Table of Contents

Article overview

SOLID Principles in PHP: Practical Examples for Better Code Quality is the kind of topic that looks simple until it reaches production. Teams usually discover the real cost late: unclear boundaries, weak defaults, hidden maintenance work, and decisions that seemed harmless when the codebase was small.

The problem gets worse when the article, tutorial, or implementation guide only explains the happy path. This guide closes that gap with a practical framework, a comparison table, common mistakes, and a deep technical section you can use while planning real work.

Keep reading for the non-obvious part: the safest implementation is rarely the most impressive-looking one. It is the one your team can debug, test, document, and evolve without turning every future change into archaeology.

Key Takeaways

  • SOLID Principles in PHP: Practical Examples for Better Code Quality should be evaluated as a production decision, not only as a syntax or tooling choice.
  • The best implementation keeps responsibilities visible, with clear ownership, tests, documentation, and rollback paths.
  • Search visibility improves when practical depth, structured answers, and expert examples live on the same page.

[IMAGE: A mobile-first technical article layout showing the main concept, decision table, implementation checklist, and FAQ blocks. Alt: SOLID Principles in PHP: Practical Examples for Better Code Quality expert guide for Architecture]

What SOLID Principles in PHP: Practical Examples for Better Code Quality means

SOLID Principles in PHP: Practical Examples for Better Code Quality means applying architecture knowledge to a concrete engineering decision, then turning that decision into reliable code, documentation, and operational behavior. In practice, it combines the topic's core concepts with trade-off analysis, implementation boundaries, testing strategy, and maintenance discipline.

This is the definition worth optimizing for featured snippets because it avoids hype. It tells the reader what the topic does and what a professional implementation must include.

Why it matters now

The technical web is more crowded than it was a few years ago. Thin tutorials can still get indexed, but they rarely earn trust from senior developers, buyers, AI answer systems, or teams that need production guidance.

For architecture topics, the strongest content now has three layers:

  • a clear answer for fast scanning
  • a practical framework for implementation
  • expert context that explains what breaks later

That same structure helps search engines understand the page. It also helps readers decide whether the advice fits their project.

Implementation framework

Use this framework before adopting the approach described in this article.

  1. Define the user problem and the production risk.
  2. Identify the smallest reliable implementation boundary.
  3. Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
  4. Add tests for the behavior that would hurt if it regressed.
  5. Document the trade-off, not only the final code.
  6. Measure the result with logs, metrics, or user-facing outcomes.
  7. Revisit the decision after real usage exposes edge cases.

The sequence is deliberately conservative. It keeps the work grounded in outcomes instead of novelty.

[IMAGE: A seven-step implementation framework with discovery, boundary design, configuration, tests, documentation, measurement, and iteration. Alt: SOLID Principles in PHP: Practical Examples for Better Code Quality implementation framework]

Practical comparison

Decision areaStrong approachWeak approachWhy it matters
ScopeSolve one clear problemMix unrelated concernsFocus improves testing and search intent
ArchitecturePut logic in explicit classes or documented boundariesHide behavior in templates or incidental callbacksFuture changes stay easier to review
Data flowPass prepared data into the view or endpointQuery or compute in presentation codeReduces regressions and performance surprises
TestingCover the risky behavior directlyTest only the happy pathCatches production failures earlier
DocumentationExplain trade-offs and limitsRepeat generic definitionsBuilds E-E-A-T and reader trust
OperationsTrack logs, metrics, and rollback stepsShip without measurementMakes the decision reversible

This table is intentionally practical. It gives a reviewer something to check before the implementation becomes expensive to change.

Expert workflow

Expert tip: "Treat SOLID Principles in PHP: Practical Examples for Better Code Quality as a system boundary. If the next developer cannot find where the decision lives, how it is tested, and when it should be avoided, the implementation is not finished."

A useful workflow is simple:

  • Start with the smallest working example.
  • Add the constraints that exist in your real project.
  • Remove anything that only demonstrates cleverness.
  • Write down the failure modes.
  • Add links to related decisions so future readers can navigate the topic cluster.

That last point matters for both humans and search systems. A single article can answer a question; a cluster proves authority.

Common mistakes

Mistake 1: Copying a pattern without its context

A pattern that works in a small demo can fail in a real application. The missing context is usually data volume, team experience, deployment process, security requirements, or observability.

Before copying the pattern, ask what assumption made it safe in the original example.

Mistake 2: Putting business logic in the wrong layer

This is the fastest way to make future debugging expensive. In Laravel, PHP, and server-rendered websites, presentation should receive prepared data, not discover rules on its own.

Keep decision logic in models, actions, services, policies, requests, jobs, or documented helpers where it can be tested directly.

Mistake 3: Optimizing for novelty instead of maintainability

Newer tools and language features can be valuable. They can also hide simple behavior behind unfamiliar syntax.

Use the option that makes the next production incident easier to understand.

Mistake 4: Publishing without a measurement plan

If the article describes a performance, SEO, security, or architecture improvement, define how success will be checked. Logs, tests, crawl diagnostics, analytics, and user behavior are all stronger than assumptions.

[IMAGE: A common-mistakes board with context loss, wrong layer, novelty bias, and missing measurement highlighted. Alt: SOLID Principles in PHP: Practical Examples for Better Code Quality common mistakes]

Image placeholders

  • [IMAGE: A concept diagram for SOLID Principles in PHP: Practical Examples for Better Code Quality with input, decision boundary, implementation, tests, and production feedback. Alt: SOLID Principles in PHP: Practical Examples for Better Code Quality concept diagram]
  • [IMAGE: A mobile screenshot-style checklist for SOLID Principles in PHP: Practical Examples for Better Code Quality. Alt: SOLID Principles in PHP: Practical Examples for Better Code Quality mobile checklist]
  • [IMAGE: A comparison table visualization for strong versus weak implementation choices. Alt: SOLID Principles in PHP: Practical Examples for Better Code Quality comparison table]

Video placeholder

[VIDEO: Insert a 5-8 minute YouTube walkthrough that demonstrates the main decision, the implementation boundary, the test strategy, and the production caveats for SOLID Principles in PHP: Practical Examples for Better Code Quality.]

Internal linking opportunities

Original Technical Deep Dive

SOLID is useful only when it changes code you can actually maintain.

It is not a reason to create an interface for every class. It is not a mandate to split a 20-line service into seven abstractions. It is a set of pressure checks:

  • Does this class have too many reasons to change?
  • Does every new behavior require editing the same old method?
  • Can a subtype be used safely wherever the parent type is expected?
  • Are clients forced to depend on methods they do not use?
  • Does business code depend on low-level infrastructure details?

This guide uses PHP examples that start messy and end with code that is easier to test, extend, and review.

The short version

PrinciplePractical PHP questionTypical refactor
Single ResponsibilityHow many reasons does this class have to change?Extract pricing, persistence, validation, notification, formatting
Open/ClosedCan I add behavior without editing a fragile conditional?Replace conditionals with policies, strategies, rules, or handlers
Liskov SubstitutionCan every implementation honor the contract without surprises?Split interfaces, remove fake implementations, keep method contracts compatible
Interface SegregationDoes this interface force clients to implement methods they do not need?Split large interfaces into role-specific interfaces
Dependency InversionDoes domain/application code know about infrastructure details?Depend on interfaces, inject adapters at the boundary

The goal is not maximum abstraction. The goal is low coupling where change is expected.

Shared example domain

The examples use a small checkout domain.

<?php

declare(strict_types=1);

final readonly class Money
{
    public function __construct(
        public int $cents,
        public string $currency,
    ) {
        if ($cents < 0) {
            throw new InvalidArgumentException('Money cannot be negative.');
        }

        if ($currency === '') {
            throw new InvalidArgumentException('Currency is required.');
        }
    }

    public static function zero(string $currency): self
    {
        return new self(0, $currency);
    }

    public function add(self $other): self
    {
        $this->assertSameCurrency($other);

        return new self($this->cents + $other->cents, $this->currency);
    }

    public function subtract(self $other): self
    {
        $this->assertSameCurrency($other);

        if ($other->cents > $this->cents) {
            throw new InvalidArgumentException('Money cannot become negative.');
        }

        return new self($this->cents - $other->cents, $this->currency);
    }

    public function multiply(float $ratio): self
    {
        return new self((int) round($this->cents * $ratio), $this->currency);
    }

    private function assertSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Currency mismatch.');
        }
    }
}

final readonly class CartLine
{
    public function __construct(
        public string $sku,
        public int $quantity,
        public Money $unitPrice,
    ) {
        if ($sku === '') {
            throw new InvalidArgumentException('SKU is required.');
        }

        if ($quantity < 1) {
            throw new InvalidArgumentException('Quantity must be positive.');
        }
    }

    public function total(): Money
    {
        return $this->unitPrice->multiply($this->quantity);
    }
}

final readonly class Cart
{
    /**
     * @param list<CartLine> $lines
     */
    public function __construct(
        public array $lines,
        public string $currency,
        public bool $customerIsVip = false,
    ) {
        if ($lines === []) {
            throw new InvalidArgumentException('Cart cannot be empty.');
        }
    }

    public function subtotal(): Money
    {
        $total = Money::zero($this->currency);

        foreach ($this->lines as $line) {
            $total = $total->add($line->total());
        }

        return $total;
    }
}

Value objects keep examples focused. A cart line is always valid. Money cannot silently mix currencies. That already removes a lot of accidental complexity before SOLID even enters the conversation.

Single Responsibility Principle

SRP is often misread as "a class should do one thing." That is too vague.

A better test is: a class should have one coherent reason to change.

SRP before: one service owns every concern

<?php

declare(strict_types=1);

final class CheckoutService
{
    public function checkout(Cart $cart, string $customerEmail): string
    {
        $subtotal = $cart->subtotal();

        $discount = Money::zero($cart->currency);

        if ($cart->customerIsVip) {
            $discount = $subtotal->multiply(0.10);
        }

        if ($subtotal->cents > 10_000) {
            $discount = $discount->add($subtotal->multiply(0.05));
        }

        $total = $subtotal->subtract($discount);

        $pdo = new PDO('mysql:host=db;dbname=shop', 'app', 'secret');

        $statement = $pdo->prepare(
            'insert into orders (email, total_cents, currency) values (?, ?, ?)',
        );

        $statement->execute([
            $customerEmail,
            $total->cents,
            $total->currency,
        ]);

        mail(
            $customerEmail,
            'Your receipt',
            sprintf('Your total was %d %s.', $total->cents, $total->currency),
        );

        return (string) $pdo->lastInsertId();
    }
}

This class changes when:

  • Discount rules change.
  • Database schema changes.
  • Email copy changes.
  • Email transport changes.
  • Order ID generation changes.
  • Transaction behavior changes.

Those changes are unrelated. That is the SRP problem.

SRP after: separate reasons to change

Extract pricing:

<?php

declare(strict_types=1);

final readonly class OrderPrice
{
    public function __construct(
        public Money $subtotal,
        public Money $discount,
        public Money $total,
    ) {}
}

final class OrderPricer
{
    public function price(Cart $cart): OrderPrice
    {
        $subtotal = $cart->subtotal();
        $discount = Money::zero($cart->currency);

        if ($cart->customerIsVip) {
            $discount = $discount->add($subtotal->multiply(0.10));
        }

        if ($subtotal->cents > 10_000) {
            $discount = $discount->add($subtotal->multiply(0.05));
        }

        return new OrderPrice(
            subtotal: $subtotal,
            discount: $discount,
            total: $subtotal->subtract($discount),
        );
    }
}

Extract persistence:

<?php

declare(strict_types=1);

final readonly class OrderRecord
{
    public function __construct(
        public string $id,
        public string $customerEmail,
        public Money $total,
    ) {}
}

interface OrderRepository
{
    public function create(string $customerEmail, Money $total): OrderRecord;
}

final readonly class PdoOrderRepository implements OrderRepository
{
    public function __construct(
        private PDO $pdo,
    ) {}

    public function create(string $customerEmail, Money $total): OrderRecord
    {
        $statement = $this->pdo->prepare(
            'insert into orders (email, total_cents, currency) values (?, ?, ?)',
        );

        $statement->execute([
            $customerEmail,
            $total->cents,
            $total->currency,
        ]);

        return new OrderRecord(
            id: (string) $this->pdo->lastInsertId(),
            customerEmail: $customerEmail,
            total: $total,
        );
    }
}

Extract receipt delivery:

<?php

declare(strict_types=1);

interface ReceiptSender
{
    public function send(OrderRecord $order): void;
}

final readonly class MailReceiptSender implements ReceiptSender
{
    public function send(OrderRecord $order): void
    {
        mail(
            $order->customerEmail,
            'Your receipt',
            sprintf(
                'Your total was %d %s.',
                $order->total->cents,
                $order->total->currency,
            ),
        );
    }
}

Now the use case coordinates the pieces:

<?php

declare(strict_types=1);

final readonly class Checkout
{
    public function __construct(
        private OrderPricer $pricer,
        private OrderRepository $orders,
        private ReceiptSender $receipts,
    ) {}

    public function __invoke(Cart $cart, string $customerEmail): OrderRecord
    {
        $price = $this->pricer->price($cart);
        $order = $this->orders->create($customerEmail, $price->total);

        $this->receipts->send($order);

        return $order;
    }
}

[IMAGE: Supporting visual 1 for SOLID Principles in PHP: Practical Examples for Better Code Quality, showing SOLID Principles in PHP: Practical Examples for Better Code Quality decisions, examples, and PHP, SOLID, Architecture. Alt: SOLID Principles in PHP: Practical Examples for Better Code Quality solid-principles-php-practical-examples-better-code-quality visual 1]

[IMAGE: Supporting visual 1 for SOLID Principles in PHP: Practical Examples for Better Code Quality, showing SOLID Principles in PHP: Practical Examples for Better Code Quality decisions, examples, and PHP, SOLID, Architecture. Alt: SOLID Principles in PHP: Practical Examples for Better Code Quality solid-principles-php-practical-examples-better-code-quality visual 1]

This is not "more classes for style." It creates separate change boundaries:

  • Pricing tests do not need a database.
  • Repository tests do not send emails.
  • Email copy changes do not touch discount logic.
  • The use case is readable at one screen.

Open/Closed Principle

OCP means code should be extendable without editing the stable core every time.

It does not mean code is never modified. It means new behavior should usually be added by adding a new implementation when the variation is already expected.

OCP before: one method keeps growing

<?php

declare(strict_types=1);

final class DiscountCalculator
{
    public function discount(Cart $cart, Coupon|null $coupon): Money
    {
        $subtotal = $cart->subtotal();
        $discount = Money::zero($cart->currency);

        if ($cart->customerIsVip) {
            $discount = $discount->add($subtotal->multiply(0.10));
        }

        if ($subtotal->cents > 10_000) {
            $discount = $discount->add($subtotal->multiply(0.05));
        }

        if ($coupon !== null && $coupon->code === 'BLACKFRIDAY') {
            $discount = $discount->add($subtotal->multiply(0.20));
        }

        if ($coupon !== null && $coupon->code === 'FREESHIPPING') {
            // This is not even a line-item discount, but it lands here anyway.
        }

        return $discount;
    }
}

Every campaign edits the same method. Merge conflicts increase. Regression risk increases. Tests become a pile of condition combinations.

OCP after: discount rules

<?php

declare(strict_types=1);

interface DiscountRule
{
    public function discount(Cart $cart, Coupon|null $coupon): Money;
}

final class VipDiscountRule implements DiscountRule
{
    public function discount(Cart $cart, Coupon|null $coupon): Money
    {
        if (! $cart->customerIsVip) {
            return Money::zero($cart->currency);
        }

        return $cart->subtotal()->multiply(0.10);
    }
}

final class LargeOrderDiscountRule implements DiscountRule
{
    public function discount(Cart $cart, Coupon|null $coupon): Money
    {
        $subtotal = $cart->subtotal();

        if ($subtotal->cents <= 10_000) {
            return Money::zero($cart->currency);
        }

        return $subtotal->multiply(0.05);
    }
}

final readonly class Coupon
{
    public function __construct(
        public string $code,
    ) {}
}

final class BlackFridayDiscountRule implements DiscountRule
{
    public function discount(Cart $cart, Coupon|null $coupon): Money
    {
        if ($coupon?->code !== 'BLACKFRIDAY') {
            return Money::zero($cart->currency);
        }

        return $cart->subtotal()->multiply(0.20);
    }
}

Aggregate the rules:

<?php

declare(strict_types=1);

final readonly class DiscountRules
{
    /**
     * @param list<DiscountRule> $rules
     */
    public function __construct(
        private array $rules,
    ) {}

    public function discount(Cart $cart, Coupon|null $coupon): Money
    {
        $discount = Money::zero($cart->currency);

        foreach ($this->rules as $rule) {
            $discount = $discount->add($rule->discount($cart, $coupon));
        }

        return $discount;
    }
}

Wiring:

$discounts = new DiscountRules([
    new VipDiscountRule(),
    new LargeOrderDiscountRule(),
    new BlackFridayDiscountRule(),
]);

Adding a new discount means adding a new rule and wiring it. Existing rule code stays untouched.

OCP is strongest when:

  • Variation is real.
  • The extension point is small.
  • Each implementation can be tested independently.
  • The order of policies is explicit.

OCP is weak when you create an abstraction before you know what will vary.

Liskov Substitution Principle

LSP asks whether a subtype can safely replace its parent type.

In PHP, this matters for:

  • Interfaces with multiple implementations.
  • Child classes extending parent classes.
  • Framework contracts.
  • Mock and fake implementations in tests.
  • Read/write repositories.

LSP before: fake implementation breaks the contract

<?php

declare(strict_types=1);

interface ProductRepository
{
    public function find(string $sku): Product|null;

    public function save(Product $product): void;
}

final class ReadOnlyProductRepository implements ProductRepository
{
    public function find(string $sku): Product|null
    {
        // Read from cache or replica.
        return null;
    }

    public function save(Product $product): void
    {
        throw new LogicException('This repository is read-only.');
    }
}

This class satisfies the syntax, but violates the contract. Code that accepts ProductRepository expects it can call save(). One implementation throws because it never should have implemented that method.

LSP after: split the capability

<?php

declare(strict_types=1);

interface ProductReader
{
    public function find(string $sku): Product|null;
}

interface ProductWriter
{
    public function save(Product $product): void;
}

interface ProductRepository extends ProductReader, ProductWriter
{
}

final class ReadOnlyProductRepository implements ProductReader
{
    public function find(string $sku): Product|null
    {
        return null;
    }
}

final class PdoProductRepository implements ProductRepository
{
    public function find(string $sku): Product|null
    {
        return null;
    }

    public function save(Product $product): void
    {
        // Persist product.
    }
}

Now read-only code asks for a reader:

<?php

declare(strict_types=1);

final readonly class ProductLookup
{
    public function __construct(
        private ProductReader $products,
    ) {}

    public function bySku(string $sku): Product|null
    {
        return $this->products->find($sku);
    }
}

Write code asks for a writer:

<?php

declare(strict_types=1);

final readonly class ProductImporter
{
    public function __construct(
        private ProductWriter $products,
    ) {}

    public function import(Product $product): void
    {
        $this->products->save($product);
    }
}

No implementation has to lie.

LSP and PHP method signatures

PHP helps enforce part of LSP with variance rules.

Return types can become more specific in child classes:

<?php

declare(strict_types=1);

interface ReceiptFactory
{
    public function make(OrderRecord $order): Receipt;
}

final class HtmlReceiptFactory implements ReceiptFactory
{
    public function make(OrderRecord $order): HtmlReceipt
    {
        return new HtmlReceipt($order);
    }
}

HtmlReceipt is a more specific kind of Receipt, so callers are safe.

Parameter types can become wider in implementations:

<?php

declare(strict_types=1);

interface ReceiptFormatter
{
    public function format(HtmlReceipt $receipt): string;
}

final class GenericReceiptFormatter implements ReceiptFormatter
{
    public function format(Receipt $receipt): string
    {
        return $receipt->toString();
    }
}

[IMAGE: Supporting visual 2 for SOLID Principles in PHP: Practical Examples for Better Code Quality, showing SOLID Principles in PHP: Practical Examples for Better Code Quality decisions, examples, and PHP, SOLID, Architecture. Alt: SOLID Principles in PHP: Practical Examples for Better Code Quality solid-principles-php-practical-examples-better-code-quality visual 2]

The implementation accepts at least what the interface promised.

Do not narrow parameters:

interface Notifier
{
    public function notify(Message $message): void;
}

final class EmailNotifier implements Notifier
{
    // Invalid idea: callers typed against Notifier may pass any Message.
    // public function notify(EmailMessage $message): void {}
}

Even when PHP allows a signature, LSP still includes behavior:

  • Do not throw new unexpected exceptions for valid parent inputs.
  • Do not require stricter input than the parent contract.
  • Do not return weaker output than the parent contract.
  • Do not silently ignore required work.
  • Do not mutate state in surprising ways.

[IMAGE: Supporting visual 2 for SOLID Principles in PHP: Practical Examples for Better Code Quality, showing SOLID Principles in PHP: Practical Examples for Better Code Quality decisions, examples, and PHP, SOLID, Architecture. Alt: SOLID Principles in PHP: Practical Examples for Better Code Quality solid-principles-php-practical-examples-better-code-quality visual 2]

Tests should exercise the contract, not only each concrete class.

Interface Segregation Principle

ISP says clients should not depend on methods they do not use.

Large interfaces usually appear when one type tries to represent every operation a subsystem can perform.

ISP before: a fat notification interface

<?php

declare(strict_types=1);

interface Notifier
{
    public function sendEmail(string $email, string $subject, string $body): void;

    public function sendSms(string $phone, string $message): void;

    public function sendSlack(string $channel, string $message): void;
}

final class EmailOnlyNotifier implements Notifier
{
    public function sendEmail(string $email, string $subject, string $body): void
    {
        // Send email.
    }

    public function sendSms(string $phone, string $message): void
    {
        throw new LogicException('SMS is not configured.');
    }

    public function sendSlack(string $channel, string $message): void
    {
        throw new LogicException('Slack is not configured.');
    }
}

The implementation is forced to fake methods it cannot support. That is also an LSP smell.

ISP after: role interfaces

<?php

declare(strict_types=1);

interface EmailSender
{
    public function sendEmail(string $email, string $subject, string $body): void;
}

interface SmsSender
{
    public function sendSms(string $phone, string $message): void;
}

interface SlackSender
{
    public function sendSlack(string $channel, string $message): void;
}

final class SmtpEmailSender implements EmailSender
{
    public function sendEmail(string $email, string $subject, string $body): void
    {
        // Send email.
    }
}

Now a receipt service depends only on email:

<?php

declare(strict_types=1);

final readonly class EmailReceiptSender implements ReceiptSender
{
    public function __construct(
        private EmailSender $emails,
    ) {}

    public function send(OrderRecord $order): void
    {
        $this->emails->sendEmail(
            $order->customerEmail,
            'Your receipt',
            sprintf('Your total was %d %s.', $order->total->cents, $order->total->currency),
        );
    }
}

A critical alert service can depend on SMS and Slack:

<?php

declare(strict_types=1);

final readonly class CriticalAlertSender
{
    public function __construct(
        private SmsSender $sms,
        private SlackSender $slack,
    ) {}

    public function send(string $phone, string $channel, string $message): void
    {
        $this->sms->sendSms($phone, $message);
        $this->slack->sendSlack($channel, $message);
    }
}

Each class states exactly what it needs.

Small interfaces are not always better. Three one-method interfaces are useful here because clients have different needs. Splitting an interface used by exactly one class into five fragments is usually noise.

Dependency Inversion Principle

DIP says high-level policy should not depend on low-level details. Both should depend on abstractions.

In PHP applications, this usually means:

  • Domain services should not instantiate PDO, Guzzle, SDK clients, or framework mailers directly.
  • Use cases should not know provider-specific payloads.
  • Infrastructure should adapt external systems to application contracts.

DIP before: business code knows Stripe and SQL

<?php

declare(strict_types=1);

final class PlaceOrder
{
    public function __invoke(Cart $cart, string $email, string $stripeToken): string
    {
        $client = new StripeClient($_ENV['STRIPE_SECRET']);
        $charge = $client->charges->create([
            'amount' => $cart->subtotal()->cents,
            'currency' => strtolower($cart->currency),
            'source' => $stripeToken,
        ]);

        if (! $charge->paid) {
            throw new RuntimeException('Payment failed.');
        }

        $pdo = new PDO($_ENV['DATABASE_URL']);
        $statement = $pdo->prepare(
            'insert into orders (email, total_cents, currency, payment_id) values (?, ?, ?, ?)',
        );

        $statement->execute([
            $email,
            $cart->subtotal()->cents,
            $cart->currency,
            $charge->id,
        ]);

        return (string) $pdo->lastInsertId();
    }
}

The use case is coupled to:

  • Stripe's SDK.
  • Stripe's payload shape.
  • Environment variables.
  • PDO.
  • SQL schema.
  • Payment failure details.

Testing this requires too much setup. Replacing Stripe requires editing the use case.

DIP after: use case depends on application contracts

<?php

declare(strict_types=1);

final readonly class Payment
{
    public function __construct(
        public string $providerReference,
    ) {}
}

interface PaymentGateway
{
    public function charge(Money $amount, string $paymentToken): Payment;
}

interface PaidOrderRepository
{
    public function create(string $email, Money $total, Payment $payment): OrderRecord;
}

final readonly class PlaceOrder
{
    public function __construct(
        private PaymentGateway $payments,
        private PaidOrderRepository $orders,
    ) {}

    public function __invoke(Cart $cart, string $email, string $paymentToken): OrderRecord
    {
        $total = $cart->subtotal();
        $payment = $this->payments->charge($total, $paymentToken);

        return $this->orders->create($email, $total, $payment);
    }
}

Stripe becomes an adapter:

<?php

declare(strict_types=1);

final readonly class StripePaymentGateway implements PaymentGateway
{
    public function __construct(
        private StripeClient $client,
    ) {}

    public function charge(Money $amount, string $paymentToken): Payment
    {
        $charge = $this->client->charges->create([
            'amount' => $amount->cents,
            'currency' => strtolower($amount->currency),
            'source' => $paymentToken,
        ]);

        if (! $charge->paid) {
            throw new RuntimeException('Payment failed.');
        }

        return new Payment((string) $charge->id);
    }
}

PDO becomes an adapter:

<?php

declare(strict_types=1);

final readonly class PdoPaidOrderRepository implements PaidOrderRepository
{
    public function __construct(
        private PDO $pdo,
    ) {}

    public function create(string $email, Money $total, Payment $payment): OrderRecord
    {
        $statement = $this->pdo->prepare(
            'insert into orders (email, total_cents, currency, payment_id) values (?, ?, ?, ?)',
        );

        $statement->execute([
            $email,
            $total->cents,
            $total->currency,
            $payment->providerReference,
        ]);

        return new OrderRecord(
            id: (string) $this->pdo->lastInsertId(),
            customerEmail: $email,
            total: $total,
        );
    }
}

The use case can now be tested with fakes:

<?php

declare(strict_types=1);

final class FakePaymentGateway implements PaymentGateway
{
    public Money|null $chargedAmount = null;

    public function charge(Money $amount, string $paymentToken): Payment
    {
        $this->chargedAmount = $amount;

        return new Payment('pay_fake_123');
    }
}

final class InMemoryPaidOrderRepository implements PaidOrderRepository
{
    /** @var list<OrderRecord> */
    public array $orders = [];

    public function create(string $email, Money $total, Payment $payment): OrderRecord
    {
        $order = new OrderRecord(
            id: (string) (count($this->orders) + 1),
            customerEmail: $email,
            total: $total,
        );

        $this->orders[] = $order;

        return $order;
    }
}

Test:

<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class PlaceOrderTest extends TestCase
{
    public function test_it_charges_and_creates_order(): void
    {
        $payments = new FakePaymentGateway();
        $orders = new InMemoryPaidOrderRepository();

        $useCase = new PlaceOrder($payments, $orders);

        $order = $useCase(
            new Cart([
                new CartLine('SKU-1', 2, new Money(1500, 'EUR')),
            ], 'EUR'),
            'customer@example.com',
            'tok_test',
        );

        self::assertSame(3000, $payments->chargedAmount?->cents);
        self::assertSame('customer@example.com', $order->customerEmail);
        self::assertCount(1, $orders->orders);
    }
}

The business flow is testable without Stripe, a database, or environment variables.

[IMAGE: Supporting visual 3 for SOLID Principles in PHP: Practical Examples for Better Code Quality, showing SOLID Principles in PHP: Practical Examples for Better Code Quality decisions, examples, and PHP, SOLID, Architecture. Alt: SOLID Principles in PHP: Practical Examples for Better Code Quality solid-principles-php-practical-examples-better-code-quality visual 3]

DIP does not mean interfaces everywhere

This is unnecessary:

interface MoneyInterface {}
interface CartLineInterface {}
interface CartInterface {}

Value objects and simple domain objects often do not need interfaces. They are data and behavior you own.

Use interfaces when:

  • You need multiple implementations.
  • You cross an infrastructure boundary.
  • You need a test double for slow or unreliable I/O.
  • You want application code independent from a vendor package.

Do not use interfaces when:

  • There is one concrete class and no expected variation.
  • The interface only mirrors the class.
  • The abstraction name is worse than the concrete name.
  • You are hiding poor design behind more files.

SOLID as a refactoring workflow

Use SOLID after you see a real change pattern.

[IMAGE: Supporting visual 3 for SOLID Principles in PHP: Practical Examples for Better Code Quality, showing SOLID Principles in PHP: Practical Examples for Better Code Quality decisions, examples, and PHP, SOLID, Architecture. Alt: SOLID Principles in PHP: Practical Examples for Better Code Quality solid-principles-php-practical-examples-better-code-quality visual 3]

  1. Start with clear, typed code.
  2. Write tests around current behavior.
  3. Identify the pain: too many reasons to change, growing conditionals, fake methods, fat interfaces, infrastructure coupling.
  4. Apply the smallest refactor that reduces that pain.
  5. Run tests.
  6. Stop when the code is easier to change.

Example review prompts:

SmellPrinciple likely involvedFirst refactor to try
One class validates, calculates, saves, sends, logsSRPExtract service by reason to change
A match grows every sprintOCPExtract strategy/rule/handler
Implementation throws "not supported"LSP, ISPSplit the interface
Interface has 12 methods but clients use 2ISPCreate role interfaces
Service creates SDK clients internallyDIPInject an application interface and move SDK code to an adapter

SOLID works best with tests because refactoring is supposed to preserve behavior.

A complete before/after smell map

Before:

final class ReportService
{
    public function sendMonthlyReport(int $customerId): void
    {
        $pdo = new PDO($_ENV['DATABASE_URL']);
        $rows = $pdo->query('select * from orders')->fetchAll();

        $html = '<h1>Report</h1>';

        foreach ($rows as $row) {
            if ((int) $row['customer_id'] === $customerId) {
                $html .= '<p>' . htmlspecialchars($row['total']) . '</p>';
            }
        }

        mail('admin@example.com', 'Monthly report', $html);
    }
}

Problems:

  • SRP: query, filtering, formatting, and delivery live together.
  • OCP: new formats or delivery channels edit the same method.
  • LSP: hard to add a fake data source without matching PDO behavior.
  • ISP: no role-specific contracts exist.
  • DIP: high-level reporting depends on PDO and mail().

After:

<?php

declare(strict_types=1);

interface MonthlyOrderReader
{
    /** @return list<OrderRecord> */
    public function forCustomer(int $customerId): array;
}

interface ReportRenderer
{
    /** @param list<OrderRecord> $orders */
    public function render(array $orders): string;
}

interface ReportDelivery
{
    public function deliver(string $subject, string $body): void;
}

final readonly class SendMonthlyReport
{
    public function __construct(
        private MonthlyOrderReader $orders,
        private ReportRenderer $renderer,
        private ReportDelivery $delivery,
    ) {}

    public function __invoke(int $customerId): void
    {
        $orders = $this->orders->forCustomer($customerId);
        $body = $this->renderer->render($orders);

        $this->delivery->deliver('Monthly report', $body);
    }
}

Now changes land in the right place:

  • SQL changes in PdoMonthlyOrderReader.
  • HTML changes in HtmlReportRenderer.
  • PDF support adds PdfReportRenderer.
  • Email provider changes in MailReportDelivery.
  • Tests can use in-memory readers and fake delivery.

[IMAGE: Supporting visual 4 for SOLID Principles in PHP: Practical Examples for Better Code Quality, showing SOLID Principles in PHP: Practical Examples for Better Code Quality decisions, examples, and PHP, SOLID, Architecture. Alt: SOLID Principles in PHP: Practical Examples for Better Code Quality solid-principles-php-practical-examples-better-code-quality visual 4]

That is what better code quality looks like: fewer unrelated edits for the same feature.

When to avoid SOLID refactors

Do not refactor just because an acronym applies.

Avoid SOLID-driven abstraction when:

  • The code is stable and rarely touched.
  • The behavior is simple and obvious.
  • There is no test coverage and no time to add it.
  • The package or module will be deleted soon.
  • The new abstraction does not have a better name than the concrete class.
  • You are only trying to satisfy a checklist.

Bad abstraction is still coupling. It just hides the coupling behind names like ManagerInterface, ProcessorInterface, and HandlerService.

Practical checklist

Before merging a SOLID refactor:

  • Tests passed before the refactor.
  • Tests pass after the refactor.
  • Behavior did not change unless the task required it.
  • Public method names explain the domain.
  • Interfaces describe roles, not classes.
  • No implementation throws "not supported" for normal contract methods.
  • Business code does not instantiate infrastructure clients.
  • New abstractions have at least one real reason to exist.
  • The diff makes future changes easier, not just more layered.

FAQ

What is SOLID Principles in PHP: Practical Examples for Better Code Quality?

SOLID Principles in PHP: Practical Examples for Better Code Quality is a practical architecture topic that should be evaluated through implementation scope, production risk, testing, documentation, and long-term maintainability.

When should a team use SOLID Principles in PHP: Practical Examples for Better Code Quality?

Use SOLID Principles in PHP: Practical Examples for Better Code Quality when it solves a real project constraint, improves clarity, or reduces operational risk. Avoid it when it only adds novelty or hides behavior from future maintainers.

What is the biggest risk with SOLID Principles in PHP: Practical Examples for Better Code Quality?

The biggest risk is copying a pattern without its context. Production systems need clear boundaries, rollback options, tests, and observability before a technique becomes dependable.

How do you test SOLID Principles in PHP: Practical Examples for Better Code Quality?

Test the smallest unit that owns the behavior, then add integration coverage for the path users or systems actually rely on. Include failure cases, configuration differences, and regression checks.

How does SOLID Principles in PHP: Practical Examples for Better Code Quality affect SEO and AI search visibility?

It improves visibility when the article gives a direct answer, expert context, structured headings, internal links, trustworthy references, and FAQ content that matches the visible page.

Conclusion

SOLID Principles in PHP: Practical Examples for Better Code Quality is worth doing when the implementation improves clarity, reliability, or delivery speed. It is not worth doing when it hides ownership, increases operational risk, or makes the system harder to explain.

Use the framework above as a review checklist. Then connect this topic to the rest of the project documentation so readers can move from concept to implementation without losing context.

Top