Object-oriented PHP is easy to write badly. A class with twenty public properties, a constructor that does database queries, methods that mutate global state — none of it throws an error, all of it runs, and all of it becomes a maintenance burden within a year. Good OOP in PHP is not about following a checklist of patterns; it is about a handful of disciplines that keep a codebase changeable as it grows, applied consistently rather than ceremonially.
Encapsulation Means Hiding Decisions, Not Just Properties
Marking a property private is the easy, mechanical part of encapsulation. The actual point is hiding a class's internal decisions — how it stores its data, how it validates a value, how it computes a derived field — behind a stable public interface, so the class is free to change those internal decisions later without breaking every piece of code that depends on it.
class Money
{
private int $cents;
public function __construct(int $cents)
{
$this->cents = $cents;
}
public function add(Money $other): Money
{
return new Money($this->cents + $other->cents);
}
public function format(): string
{
return number_format($this->cents / 100, 2);
}
}Nothing outside Money needs to know it stores cents as an integer rather than a float, or that addition is implemented by creating a new instance rather than mutating in place. Callers only see add() and format(). That boundary is what lets the internal representation change later — switching to a different rounding strategy, for instance — without touching a single caller.
Composition Over Inheritance
Inheritance creates a tight, fragile coupling between a parent and every child class, since a change to the parent's behavior ripples automatically into every subclass whether or not that subclass actually wants the change. Composition — building a class out of smaller, focused collaborator objects passed in rather than inherited from — keeps that coupling explicit and loose instead.
class OrderProcessor
{
public function __construct(
private PaymentGateway $gateway,
private InventoryService $inventory,
private NotificationService $notifier
) {}
public function process(Order $order): void
{
$this->inventory->reserve($order->items);
$this->gateway->charge($order->total);
$this->notifier->send($order->customerEmail, "order-confirmed");
}
}Swapping PaymentGateway for a different implementation, or mocking it entirely in a test, requires no inheritance hierarchy changes at all — just passing a different object into the constructor. This is, in practice, the single highest-leverage habit for keeping a PHP codebase testable as it grows.
Interfaces Define Contracts, Not Just Types
An interface in PHP is a promise about behavior, not just a type-hint that satisfies a static analyzer. A well-designed interface describes exactly what a caller needs and nothing more, which is what allows multiple, very different implementations to satisfy it interchangeably.
interface PaymentGateway
{
public function charge(int $amountCents): PaymentResult;
}
class StripeGateway implements PaymentGateway { /* ... */ }
class PayPalGateway implements PaymentGateway { /* ... */ }
class TestGateway implements PaymentGateway { /* ... */ }OrderProcessor never needs to know which implementation it received. That ignorance is the entire value of the interface — it is what lets you swap a real payment gateway for a fast, deterministic test double in a test suite, with zero changes to the class under test.
Avoid God Objects and Static State
A class that knows how to validate input, persist to the database, send emails, and generate reports is a god object — everything routes through it, every change to any of those responsibilities risks breaking every other responsibility, and testing any one piece in isolation becomes impossible without dragging the entire object along. Static methods and static properties compound the problem by making dependencies invisible and global, untestable without resetting hidden shared state between tests.
The fix is the same single-responsibility discipline applied consistently: each class does one thing, dependencies are passed in explicitly rather than reached for globally, and a class's public methods read like a short, coherent description of one job rather than a grab-bag of unrelated capabilities.
Value Objects for Primitive Obsession
Passing raw strings and integers around for concepts that have their own rules — an email address, a phone number, a currency amount — scatters validation and formatting logic across every place that touches the value. A small value object (like the Money class above) centralizes that logic once, makes invalid states unrepresentable at construction time, and gives the concept a real name in your code instead of an anonymous string or int.
class EmailAddress
{
private string $value;
public function __construct(string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Invalid email: $value");
}
$this->value = strtolower($value);
}
public function __toString(): string
{
return $this->value;
}
}Favor Immutability Where Practical
A mutable object whose state can change after creation is harder to reason about, since any code holding a reference to it can be surprised by a change made elsewhere. Designing objects to be immutable by default — methods return a new instance rather than mutating in place, as Money's add() does — eliminates an entire category of "who changed this and when" bugs, at the modest cost of allocating new objects more often.
Avoid Leaking Implementation Through Getters and Setters
Generating a getter and setter for every private property defeats the purpose of encapsulation just as thoroughly as making everything public — callers end up reaching in and mutating internal state directly through the setter, same as if it were public, just with extra ceremony. Ask what behavior a class needs to expose, not what data it happens to store, and write methods named for that behavior (markAsShipped(), not setStatus('shipped')).
Dependency Injection Containers Are a Tool, Not a Requirement
Laravel's service container resolves constructor dependencies automatically, which is genuinely convenient, but it can also obscure how many dependencies a class actually has if used carelessly — a constructor with eight auto-resolved dependencies is still a class doing too much, even though the container hides the friction of wiring it up. Let a long constructor argument list be a signal to split the class, not just a convenience the container quietly absorbs.
Case Study: The Three-Thousand-Line God Class
A team inherited a UserManager class that had grown to over three thousand lines across two years — handling registration, authentication, profile updates, subscription billing, and email preferences, all in one class with dozens of public methods and several static helpers other parts of the codebase called directly. Every change to any one responsibility risked breaking an unrelated one, and the test suite for it took twenty minutes to run because every test had to boot the entire class's dependency graph. The team split it incrementally over several months — pulling one responsibility out at a time into its own class with a clean interface, starting with billing since it was the most isolated — rather than attempting a single large rewrite. Eighteen months later the original class was gone entirely, replaced by six focused classes, each independently testable in under a second.
A Glossary for This Topic
Encapsulation: hiding a class's internal implementation details behind a stable public interface. Composition: building a class from smaller collaborator objects rather than inheriting behavior from a parent class. SOLID: five design principles (single responsibility, open/closed, Liskov substitution, interface segregation, dependency inversion) commonly cited for maintainable OOP. God object: a class that has accumulated far too many unrelated responsibilities. Value object: a small, often immutable object representing a domain concept with its own validation rules.
Frequently Asked Questions
Is inheritance always bad? No — inheritance is appropriate for a genuine "is-a" relationship with shared behavior unlikely to diverge, but it is overused far more often than it is correctly used in typical application code.
Do I need a DTO for every database table? Not necessarily, but for data crossing a boundary (an API response, a queued job payload) a dedicated, explicit data object is usually clearer than passing an Eloquent model or a raw array around.
How many responsibilities should a class have? One, expressed concretely: if you cannot describe what a class does in a single short sentence without using "and," it likely has more than one responsibility.
Step-by-Step: Refactoring a God Class Safely
First, write characterization tests around the god class's current behavior before changing anything, so you have a safety net proving you haven't broken existing functionality. Second, identify the most isolated responsibility — one with the fewest dependencies on the class's other responsibilities — and extract it into a new class first, since it carries the least risk. Third, have the god class delegate to the new class rather than duplicating logic, keeping a single source of truth during the transition. Fourth, run the full test suite and confirm no behavior changed. Fifth, repeat for the next-most-isolated responsibility, working from easiest to hardest rather than attempting the most entangled responsibility first.
A Comparison Table: Inheritance vs Composition
| Aspect | Inheritance | Composition |
|---|---|---|
| Coupling | Tight, parent changes ripple to children | Loose, swap collaborators independently |
| Testability | Harder, must instantiate full hierarchy | Easier, inject test doubles |
| Flexibility | Fixed at compile time | Can change at runtime |
| Best for | True is-a relationships | Has-a, uses-a relationships |
Security Considerations Checklist
Never expose internal class structure through serialization without reviewing what gets included — an Eloquent model's default array or JSON cast can leak a hashed password field or internal flags if not explicitly hidden. Validate constructor arguments for value objects rigorously, since a value object's entire purpose is making invalid states unrepresentable, which only works if construction actually enforces it. Avoid storing secrets (API keys, credentials) as plain public properties on any class that might end up logged or serialized accidentally.
Accessibility Considerations
Clean OOP design has no direct accessibility dimension, but it has an indirect one worth naming: a well-structured, testable codebase makes it meaningfully easier to add and maintain accessibility features over time, since a focused, single-responsibility view-rendering class is far easier to audit and fix for accessibility than a tangled one mixing rendering with business logic and side effects.
How This Plays Out at Different Scales
A small script or prototype can reasonably skip much of this discipline, since the cost of a god class in throwaway code is low. A growing application maintained by more than one or two people benefits enormously from the disciplines in this guide, since the cost of tangled responsibilities compounds with every additional contributor touching the same code. A large, long-lived codebase with years of history depends on this discipline having been applied consistently from early on, since retrofitting it later (as in the case study above) is real, costly work.
What to Do When You Inherit a Codebase Full of God Classes
Inheriting a large codebase where most business logic lives in a handful of massive, tangled classes is not a reason to attempt a wholesale rewrite, which is rarely justified and frequently fails outright. Apply the strangler-fig approach from the case study above: leave the god classes in place, extract one responsibility at a time into a new, focused, well-tested class, have the old class delegate to the new one, and let the god classes shrink gradually over months as ordinary feature work touches them, rather than declaring a dedicated, large-scope refactor project that competes directly with feature delivery and risks being deprioritized halfway through.
Final Checklist Before Calling a Refactor Done
Each extracted class has a single, clearly nameable responsibility. The original god class either delegates entirely to extracted classes or has been deleted outright. Test coverage exists for each extracted class independently, not just for the original god class as a whole. No new caller was written against the old god class's interface during the refactor, which would undo the separation just achieved. The team agrees the codebase is meaningfully easier to change than before, not just structurally different.
Closing Thought, Revisited
Good object-oriented design in PHP is not about applying every pattern from a textbook; it is about making a specific, practical bet that the code will need to change again, and arranging it so that change is cheap when it inevitably comes. Every encapsulation boundary, every composed dependency, every focused class is a small investment paid back the first time someone needs to extend or fix the system without understanding all of it at once.
Traits: Use Sparingly
PHP traits let you mix in shared method implementations across unrelated classes without inheritance, which solves the real problem of code duplication across classes that do not share an "is-a" relationship. The risk is overuse: a class composed of five traits, each modifying behavior in ways not visible just by reading the class itself, becomes hard to reason about for the same reason deep inheritance hierarchies do. Reserve traits for small, genuinely orthogonal concerns (a Loggable trait, a HasUuid trait) rather than as a general substitute for proper composition.
Naming Reveals Design Quality
A class or method that is hard to name well is often a class or method doing more than one thing — "and" in a method name (validateAndSave, fetchAndNotify) is frequently a signal the method should be split. Spending real effort on naming is not a cosmetic exercise; it is a fast, cheap way to surface design problems before they get baked into a wider API that becomes expensive to change later.
Final Polymorphism Without Inheritance Chains
PHP interfaces give you polymorphism — treating different concrete classes interchangeably through a shared contract — without needing a shared base class at all. A NotificationChannel interface implemented independently by EmailChannel, SmsChannel, and SlackChannel lets a Notifier class loop over a list of channels and call send() on each, with no inheritance relationship between the channel classes whatsoever, and no risk of one channel's implementation details leaking into another through a shared parent.
Readonly Properties for Stronger Immutability
PHP's readonly property modifier enforces immutability at the language level rather than relying purely on convention — once set in the constructor, a readonly property cannot be reassigned, and attempting to do so throws an error rather than silently succeeding. For value objects and DTOs specifically, readonly properties make the immutability this guide recommends a guarantee the language itself enforces, not just a discipline developers have to remember and maintain by hand.