Skip to main content

Object-Oriented Programming in PHP

Object-oriented programming (OOP) is a way of structuring code around objects—self-contained units that combine state and behavior. In PHP, OOP isn’t a mandatory paradigm; you can write functional or procedural code just as well. But for backend systems that grow beyond a handful of scripts, object-oriented design helps manage complexity, encapsulate business rules, and build code that can be tested and maintained over time.

This article covers practical OOP in PHP 8.4. You’ll learn how to model responsibilities with classes, enforce contracts with interfaces, assemble flexible designs using composition, and avoid the design traps that make object-oriented code harder to understand rather than easier.

What You Will Learn​

  • The difference between a class and an object
  • Defining classes with properties, methods, and constructors
  • Controlling visibility to enforce encapsulation
  • Using inheritance, abstract classes, and interfaces effectively
  • Achieving polymorphic behavior without deep inheritance trees
  • Applying composition over inheritance for maintainable designs
  • Injecting dependencies to keep classes testable and decoupled
  • Leveraging modern PHP features such as enums, readonly properties, and constructor promotion
  • Identifying common OOP mistakes and how to fix them

What Is Object-Oriented Programming?​

At its core, OOP models your application as a collection of objects that interact. A class is a blueprint; an object is an instance built from that blueprint. The object holds data (its state) and exposes methods (its behavior).

OOP becomes valuable when you need to:

  • Protect invariants – guarantee that a bank account balance never goes negative, that an email address is always valid.
  • Organize growing codebases – group related data and functions into well‑named, focused classes.
  • Replace implementations – swap a database driver or payment gateway without rewriting business logic.
  • Test in isolation – replace slow or external collaborators with fast, predictable doubles.

It is not a silver bullet. Many small scripts, data‑transformation pipelines, or simple API proxies are better served by plain functions and arrays. The goal is not to write “as many classes as possible” but to use objects where they clarify responsibilities and boundaries.

Classes and Objects in PHP​

A class is declared with the class keyword. You create an instance using new.

declare(strict_types=1);

class Product
{
public string $sku;
public string $name;
public int $priceInCents;
}

$product = new Product();
$product->sku = 'PHP-BOOK-001';
$product->name = 'Mastering PHP 8.4';
$product->priceInCents = 3990;

The $this pseudo‑variable refers to the current object instance inside a method. Object identity is preserved when you pass an object to a function—you’re passing a reference to the same object.

Modern PHP pushes you to initialize state through the constructor rather than setting public properties after creation. This leads to more reliable objects that are never in an incomplete state.

Properties, Methods, and Visibility​

Visibility defines who can access a member:

  • public – accessible from anywhere.
  • protected – accessible within the class itself and its descendants.
  • private – accessible only within the defining class.

Exposing public mutable properties is tempting but undermines encapsulation. A class should offer intentional, behavior‑oriented methods instead of letting any external code modify its internals directly.

declare(strict_types=1);

class Product
{
public function __construct(
private string $sku,
private string $name,
private int $priceInCents,
) {}

public function getPriceInCents(): int
{
return $this->priceInCents;
}

public function changePrice(int $newPrice): void
{
if ($newPrice <= 0) {
throw new \InvalidArgumentException('Price must be positive.');
}
$this->priceInCents = $newPrice;
}
}

Here the property is private; the only way to modify the price is through changePrice(), which validates the new value. This prevents accidental corruption of the object’s state.

Static properties and methods belong to the class itself, not to an instance. They can be useful for pure utility functions or constants, but static state that changes (e.g., a “global” configuration) makes testing and reasoning much harder because the state is not isolated per object.

Constructors and Object Initialization​

The __construct() method is called automatically when you create an object. Use it to require mandatory dependencies and to validate input before the object is used.

Constructor property promotion, available since PHP 8.0, reduces boilerplate:

final class Customer
{
public function __construct(
private string $name,
private string $email,
) {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Invalid email');
}
}
}

A constructor should:

  • Set required state.
  • Validate it so the object cannot exist in an invalid state.
  • Accept explicit dependencies (collaborators) rather than pulling them from a global container or service locator.

Avoid performing expensive work (like database queries, network calls) directly in a constructor—that makes the class hard to instantiate in tests and hides side effects. If setup requires such work, consider a factory or a dedicated initializer.

Encapsulation​

Encapsulation is often misunderstood as “make everything private and add getters/setters.” True encapsulation means that the object controls its own state and exposes behavior that preserves its invariants.

A BankAccount class with a private $balance and a public withdraw(int $amount): Money method is encapsulated. A User class with getEmail() / setEmail() that simply returns or assigns a string is not meaningfully encapsulated—it’s just a struct with extra steps.

declare(strict_types=1);

class BankAccount
{
public function __construct(
private int $balanceInCents = 0,
) {}

public function deposit(int $amount): void
{
if ($amount <= 0) {
throw new \InvalidArgumentException('Deposit amount must be positive.');
}
$this->balanceInCents += $amount;
}

public function withdraw(int $amount): void
{
if ($amount <= 0) {
throw new \InvalidArgumentException('Withdrawal amount must be positive.');
}
if ($amount > $this->balanceInCents) {
throw new \RuntimeException('Insufficient funds.');
}
$this->balanceInCents -= $amount;
}

public function getBalanceInCents(): int
{
return $this->balanceInCents;
}
}

Encapsulation is about making invalid states impossible, not about hiding data for the sake of hiding it.

Static Methods and Constants​

Class constants define fixed values that belong to a class:

class ApiConfig
{
public const VERSION = 'v1';
public const TIMEOUT = 30;
}

Constants can be public, protected, or private. Since PHP 8.3, you can also have typed class constants.

Static methods are pure functions attached to a class. They are appropriate for operations that do not depend on instance state (e.g., StringUtil::slugify()). However, static methods that rely on hidden shared state (like a static database connection) are problematic—they introduce global coupling that makes tests brittle and code harder to understand.

When in doubt, prefer instance methods with explicit dependencies passed through the constructor.

Inheritance​

Inheritance lets a class extend another, inheriting its public and protected members. Use it only when the child class truly is‑a specific kind of the parent, and can be substituted without surprise.

class DigitalProduct extends Product
{
public function __construct(
string $sku,
string $name,
int $priceInCents,
private readonly string $downloadUrl,
) {
parent::__construct($sku, $name, $priceInCents);
}

public function getDownloadUrl(): string
{
return $this->downloadUrl;
}
}

Mark classes and methods that aren’t designed for inheritance with the final keyword. This prevents accidental extension and communicates design intent clearly.

Deep inheritance hierarchies (three or more levels) tend to become fragile—changes to a base class can ripple unpredictably. Prefer composition (assembling objects from smaller collaborators) when you need to reuse behavior across unrelated classes.

Abstract Classes​

An abstract class provides a partial implementation that child classes must complete. It can define abstract methods that have no body.

abstract class Report
{
public function generate(): string
{
$data = $this->fetchData();
return $this->format($data);
}

abstract protected function fetchData(): array;
abstract protected function format(array $data): string;
}

Abstract classes are useful when you have a shared algorithm but step‑specific details. However, because PHP supports only single inheritance, an abstract class locks the hierarchy. Often, an interface combined with a trait or a separate helper class is more flexible.

Interfaces and Contracts​

An interface defines a contract without any implementation. It declares method signatures that implementing classes must fulfill.

interface PaymentGateway
{
public function charge(int $amountInCents, string $token): PaymentResult;
}

A class implements an interface with the implements keyword. A class can implement multiple interfaces, which is one of PHP’s key tools for polymorphism.

Interfaces are crucial for:

  • Testing – you can mock or stub the interface in tests.
  • Decoupling – high‑level code depends on the interface, not on a concrete gateway or database adapter.
  • Replacing implementations – swapping a PayPal gateway for a Stripe one becomes trivial.

But don’t create interfaces prematurely. An interface should exist because you have at least two meaningful implementations, or because you clearly need to isolate a boundary (like a payment gateway). Adding an interface for every single class creates unnecessary indirection.

Polymorphism​

Polymorphism allows code to work with objects of different types through a shared interface or base class. The calling code doesn’t need to know which concrete implementation it’s using.

final class Checkout
{
public function __construct(
private PaymentGateway $gateway,
) {}

public function process(Cart $cart, string $token): PaymentResult
{
$amount = $cart->getTotalInCents();
return $this->gateway->charge($amount, $token);
}
}

You can pass any PaymentGateway implementation to Checkout—a real charge, a fake for testing, or a future provider. This keeps the checkout logic clean and focused on its own workflow.

Polymorphism doesn’t require inheritance; it requires that different objects respond to the same message (method call). Interfaces give you that without the baggage of shared parent classes.

Composition Over Inheritance​

Composition means building objects by combining smaller, focused collaborators rather than extending a monolithic base.

Imagine a NotificationService that needs to email and log. With inheritance, you might try to build an EmailNotification and a LoggingEmailNotification hierarchy. With composition, you inject separate Mailer and Logger objects:

final class NotificationService
{
public function __construct(
private Mailer $mailer,
private Logger $logger,
) {}

public function notify(User $user, string $message): void
{
$this->mailer->send($user->email, $message);
$this->logger->info('Notification sent', ['user' => $user->id]);
}
}

Now you can swap the Mailer or Logger independently. Both collaborators can be tested in isolation. The design is more flexible and easier to understand than a long inheritance chain.

Inheritance is still useful for true “is‑a” relationships, such as StandardProduct and DigitalProduct both being Product. But for sharing behavior across unrelated classes, composition is the safer path.

Dependency Injection​

Dependency injection means providing an object’s collaborators from the outside, typically through the constructor. It avoids hidden, hard‑coded dependencies and makes the object’s requirements explicit.

interface Logger
{
public function info(string $message): void;
}

final class OrderService
{
public function __construct(
private Logger $logger,
private PaymentGateway $gateway,
) {}

public function placeOrder(Order $order, string $token): void
{
// ...
$this->logger->info('Order placed');
}
}

You don’t need a framework or container to practice dependency injection. In a simple entry point, you instantiate the concrete dependencies and wire them together by hand. When you later explore Dependency Injection, you’ll see how containers automate that wiring without breaking the fundamental principle: dependencies are explicit, not pulled from global state.

Traits​

A trait is a reusable piece of implementation that can be shared across unrelated classes.

trait HasTimestamps
{
public DateTimeImmutable $createdAt;
public DateTimeImmutable $updatedAt;

public function initializeTimestamps(): void
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
}

A class uses a trait with the use keyword. Traits can resolve method conflicts, and you can change the visibility of an imported method with aliasing.

Traits are convenient for sharing cross‑cutting concerns like logging, timestamping, or caching. However, they are not a replacement for interfaces or good composition. Traits hide dependencies—a class that uses a trait with a cache method depends on a cache, but that dependency isn’t visible in its constructor. Prefer composition when the collaboration is a core part of the design; use traits for implementation details that don’t define the object’s primary responsibilities.

Enums, Readonly, and Modern PHP OOP Features​

Modern PHP (8.1+) introduces enums, which model a fixed set of possible values:

enum OrderStatus: string
{
case Pending = 'pending';
case Confirmed = 'confirmed';
case Shipped = 'shipped';
case Delivered = 'delivered';
}

Enums can contain methods and implement interfaces. Backed enums map each case to a scalar value. This replaces fragile string constants and clarifies intent.

Readonly properties (PHP 8.1) and readonly classes (PHP 8.2) prevent reassignment after initialization. They are valuable for data that should not change once set:

readonly class Address
{
public function __construct(
public string $street,
public string $city,
public string $country,
) {}
}

Important: readonly is a shallow guard. It prevents rebinding the property, but if the property holds an object, that object’s internal state can still change unless it is itself immutable. Plan for deep immutability by designing the referenced objects to be immutable as well.

Other modern features include:

  • Constructor property promotion (8.0) – reduces boilerplate.
  • Attributes (8.0) – structured metadata instead of docblock annotations.
  • Intersection types (8.1) – e.g., Countable&Iterator.
  • First‑class callable syntax (8.1) – $fn = $object->method(...).

Each feature should be adopted when it makes your design clearer and more robust, not just for the sake of novelty.

Value Objects and Data Objects​

Distinguish between different kinds of objects:

  • Value objects represent a value by its attributes. They are immutable and compared by equality of their fields. Example: Money, EmailAddress, UserId.
  • Entities have identity that persists even when their attributes change (e.g., a User or Order identified by an ID).
  • Data transfer objects (DTOs) are simple containers for moving data between layers, without rich behavior.
  • Services are objects that perform operations, often stateless.

A simple value object:

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

public function equals(self $other): bool
{
return $this->amountInCents === $other->amountInCents
&& $this->currency === $other->currency;
}
}

Value objects protect integrity and eliminate primitive obsession—passing a Money instance is far clearer than passing int $amount and string $currency separately.

For deeper exploration of entities, aggregates, and domain modelling, see Domain-Driven Design.

Object Equality, Identity, and Cloning​

PHP compares objects by identity, not by value:

  • $a == $b returns true if both objects have the same attributes and are of the same class.
  • $a === $b returns true only if they are the same instance (same object reference).

Value objects usually override equality by providing an equals() method that compares internal fields.

Cloning creates a shallow copy of an object:

$copy = clone $original;

If the object holds references to other objects, the copy will reference the same sub‑objects unless you implement the __clone() magic method to perform deep copies. Prefer immutability over cloning—immutable objects don’t need to be cloned because they never change; you simply create a new instance with the desired modifications.

Namespaces and Autoloading​

In real PHP projects, classes live in files organized by namespace, and Composer autoloads them automatically. A namespace groups related classes and prevents name collisions:

namespace App\Domain\Value;

readonly class EmailAddress
{
public function __construct(
public string $value,
) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Invalid email');
}
}
}

You import a class with the use statement:

use App\Domain\Value\EmailAddress;

Modern PHP relies on PSR‑4 autoloading and Composer to map namespaces to directory paths. You never need to write manual require statements for your own classes.

A Complete Object-Oriented PHP Example​

The following example combines several concepts into a cohesive, working snippet. It models a simplified order processing scenario with a value object, an interface for payment, and a service with explicit dependencies.

<?php

declare(strict_types=1);

namespace App\Shop;

use DateTimeImmutable;

// Value object: Money
readonly class Money
{
public function __construct(
public int $amountInCents,
public string $currency = 'USD',
) {
if ($amountInCents < 0) {
throw new \InvalidArgumentException('Amount cannot be negative.');
}
}

public function equals(self $other): bool
{
return $this->amountInCents === $other->amountInCents
&& $this->currency === $other->currency;
}
}

// Entity: Order
class Order
{
public readonly DateTimeImmutable $createdAt;

/** @var OrderLine[] */
private array $lines = [];

public function __construct(
public readonly string $orderId,
) {
$this->createdAt = new DateTimeImmutable();
}

public function addLine(string $productId, Money $price, int $quantity): void
{
$this->lines[] = new OrderLine($productId, $price, $quantity);
}

public function getTotal(): Money
{
$totalCents = 0;
foreach ($this->lines as $line) {
$totalCents += $line->price->amountInCents * $line->quantity;
}
return new Money($totalCents);
}
}

readonly class OrderLine
{
public function __construct(
public string $productId,
public Money $price,
public int $quantity,
) {
if ($quantity <= 0) {
throw new \InvalidArgumentException('Quantity must be positive.');
}
}
}

// Interface: payment gateway contract
interface PaymentGateway
{
public function charge(Money $amount, string $token): bool;
}

// Service: order processing with explicit dependencies
final class OrderService
{
public function __construct(
private PaymentGateway $gateway,
private LoggerInterface $logger,
) {}

public function placeOrder(Order $order, string $paymentToken): bool
{
$total = $order->getTotal();
$this->logger->info("Charging {$total->amountInCents} cents for order {$order->orderId}");

return $this->gateway->charge($total, $paymentToken);
}
}

interface LoggerInterface
{
public function info(string $message): void;
}

In your entry point, you wire the dependencies:

// In a real application, these would be actual implementations.
$gateway = new class implements PaymentGateway {
public function charge(Money $amount, string $token): bool {
// ... call Stripe/PayPal
return true;
}
};
$logger = new class implements LoggerInterface {
public function info(string $message): void {
echo "[INFO] $message\n";
}
};

$orderService = new OrderService($gateway, $logger);
$order = new Order('ORD-123');
$order->addLine('PROD-1', new Money(2990), 2);
$orderService->placeOrder($order, 'tok_visa');

This design is framework‑agnostic, testable, and respects encapsulation. It demonstrates why objects help manage real‑world complexity.

Testing Object-Oriented PHP Code​

Well‑designed OOP code is easier to test because dependencies are explicit. You can replace the real PaymentGateway with a test double in a PHPUnit test:

$gateway = $this->createMock(PaymentGateway::class);
$gateway->expects($this->once())
->method('charge')
->willReturn(true);

$service = new OrderService($gateway, new NullLogger());
$result = $service->placeOrder($order, 'fake-token');

$this->assertTrue($result);

Test public behavior, not private methods. Value objects can be tested directly by verifying that invalid states throw exceptions and that equality works. Designing interfaces at the right boundaries makes mocking natural.

For a full testing guide, see PHPUnit Testing and Testing Best Practices.

Common Object-Oriented PHP Mistakes​

  • Anemic models: Classes that are nothing but public properties or getters/setters with no behavior. Move logic that operates on those properties into the class.
  • “Manager” / “Helper” classes: Huge classes that do everything. Split them into focused services with explicit names.
  • Deep inheritance chains: They become fragile. Favor composition.
  • Static everything: Hides dependencies, kills testability. Use instances with injected collaborators.
  • Interfaces without purpose: Not every class needs an interface. Define interfaces when you have a real substitution point.
  • Traits as a substitute for composition: If a trait represents a core collaboration, refactor it into a proper injected service.
  • Constructors doing I/O: Makes object creation unpredictable. Defer such work to a factory or a dedicated method.
  • Catching Throwable everywhere: Masks fatal errors. Catch only specific exception types you can handle.
  • Using mixed or untyped returns when a concrete type is possible: Type information reduces bugs.
  • Confusing DTOs with domain objects: DTOs carry data; domain objects enforce rules. Keep them separate.

Practical Object-Oriented Design Guidelines​

  • Give each class a single, well‑defined responsibility.
  • Protect invariants inside the class; don’t let callers arbitrarily modify state.
  • Prefer constructor injection for required dependencies.
  • Use composition to assemble complex behavior from small, focused objects.
  • Create interfaces at meaningful boundaries where you expect multiple implementations or need to isolate I/O.
  • Keep public APIs minimal—expose only what callers genuinely need.
  • Use readonly and immutability to reduce side‑effect bugs.
  • Use types (int, string, custom value objects) to communicate intent and constraints.
  • Don’t build abstractions for hypothetical future needs; design for the code you have today.
  • Keep framework annotations and configuration out of your domain classes; keep them in framework‑specific layers (see Architecture).

The SOLID principles are a helpful lens:

  • Single Responsibility: A class should have only one reason to change.
  • Open/Closed: Extend behavior by adding new code (new implementations), not by modifying existing classes.
  • Liskov Substitution: Subtypes must be usable wherever the parent type is expected.
  • Interface Segregation: Prefer small, focused interfaces over “fat” ones.
  • Dependency Inversion: Depend on abstractions (interfaces), not on concrete implementations.

Apply them with pragmatism, not dogma.

Object-Oriented PHP Checklist​

  • I can define a class and instantiate objects.
  • I use typed properties and methods with explicit return types.
  • I set visibility (public, protected, private) intentionally.
  • I initialize required state in the constructor and validate it.
  • I expose behavior methods that protect invariants, not raw setters.
  • I use inheritance only for genuine “is‑a” relationships and mark non‑inheritable classes final.
  • I understand when to use an abstract class vs an interface.
  • I program to interfaces at critical boundaries.
  • I achieve polymorphism primarily through interfaces.
  • I favor composition over inheritance.
  • I inject dependencies through the constructor rather than hiding them.
  • I use traits sparingly and not as a substitute for proper composition.
  • I leverage enums, readonly classes, and constructor promotion where they improve clarity.
  • I design value objects for important domain primitives.
  • I organize classes into namespaces and rely on Composer autoloading.
  • My classes are testable: collaborators can be replaced with doubles.
  • I avoid common pitfalls like anemic models, deep hierarchies, and static service locators.

Conclusion​

Object-oriented programming in PHP is a tool for managing complexity. The language gives you classes, interfaces, traits, and a rich type system—but effective design comes from intentional decisions about responsibilities, boundaries, and collaboration patterns.

When you write a class, you’re not just storing data; you’re defining a protected space with clear rules. When you depend on an interface, you’re making a promise that different implementations can be swapped. When you use composition, you’re building a system from understandable parts rather than a single tangled hierarchy.

These skills are the foundation for everything that follows: working with Composer packages, building Laravel or Symfony applications, applying Clean Architecture patterns, and writing automated tests. The next step is to see how namespaces and autoloading let you organize these classes into real projects—take a look at Namespaces and Autoloading and Composer. Then, explore how Dependency Injection and Architecture help you scale these designs to full applications.