PHP's object-oriented programming features exist to help you organise complex code, not to satisfy a requirement to use classes everywhere. The question is not whether to use OOP but when to use it. For simple scripts that process a form, query a database, and render a page, a functional approach with functions is often clearer, faster to write, and easier to debug than a class-based equivalent. For complex applications where multiple pieces of code need to share behaviour, maintain state, or be composed into larger structures, classes provide organisation that would otherwise collapse under its own weight.

This article covers the decision criteria for when object-oriented programming in PHP is the right tool, and when it adds unnecessary complexity that will make your code harder to understand and maintain.

The Core Benefit of Classes: Encapsulation and Shared State

The reason classes exist is that sometimes you need multiple parts of your application to share behaviour and state that persists between function calls. A database connection is the canonical example: you connect once, you use that connection repeatedly across many different parts of your application, and the connection object maintains its own state. Without a class to encapsulate this, you either pass connection variables through every function call or use global variables, both of which introduce problems.

A class gives you a clean container for stateful behaviour. The database connection class encapsulates the connection handle, provides methods for querying that use the connection internally, and maintains its own error state. Every part of your application that needs the database uses the same class instance, which shares the same underlying connection.

The key insight is that classes solve a specific problem: managing shared, stateful behaviour across multiple call sites. If you do not have that problem, you probably do not need a class. This principle applies whether you are building a small business website or a complex SaaS application. Understanding when to apply object-oriented design patterns and when to keep things simple is a skill that develops through practical experience.

When Classes Are the Right Tool

Database Abstraction and Repository Pattern

Wrapping database queries in a class provides a consistent interface for your application code. You write queries against the class interface rather than raw PDO calls, which means you can change the underlying database engine without rewriting the queries throughout your application. The Repository pattern is the standard approach for this kind of database abstraction in PHP applications.

When you separate your data access logic into repository classes, you gain flexibility in how you structure your application. If you need to change from MySQL to PostgreSQL, you update the repository implementation rather than hunting through your entire codebase. If you want to add caching, you modify the repository. If you need to write tests that do not touch the real database, you create a mock repository that implements the same interface.

class InvoiceRepository {

    private PDO $db;

    public function __construct(PDO $db) {
        $this->db = $db;
    }

    public function find(int $id): ?Invoice {
        $stmt = $this->db->prepare('SELECT * FROM invoices WHERE id = ?');
        $stmt->execute([$id]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ? Invoice::fromRow($row) : null;
    }

    public function findByClient(int $clientId): array {
        $stmt = $this->db->prepare(
            'SELECT * FROM invoices WHERE client_id = ? ORDER BY created_at DESC'
        );
        $stmt->execute([$clientId]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    public function save(Invoice $invoice): void {
        $data = $invoice->toArray();
        if ($invoice->getId() === null) {
            $columns = implode(', ', array_keys($data));
            $placeholders = implode(', ', array_fill(0, count($data), '?'));
            $sql = "INSERT INTO invoices ({$columns}) VALUES ({$placeholders})";
            $stmt = $this->db->prepare($sql);
            $stmt->execute(array_values($data));
            $invoice->setId($this->db->lastInsertId());
        } else {
            $setParts = [];
            foreach (array_keys($data) as $column) {
                if ($column !== 'id') {
                    $setParts[] = "{$column} = ?";
                }
            }
            $sql = "UPDATE invoices SET " . implode(', ', $setParts) . " WHERE id = ?";
            $values = array_values($data);
            $values[] = $invoice->getId();
            $stmt = $this->db->prepare($sql);
            $stmt->execute($values);
        }
    }
}

External Service Integration

When your application integrates with external APIs, wrapping the integration in a class gives you a single place where that responsibility lives. Whether you are connecting to payment processors, email providers, SMS gateways, or third-party data services, a dedicated service class keeps the integration contained.

If the external API changes, you update the class. If you switch providers, you replace the class. Without this encapsulation, API calls scatter throughout your codebase and changing a single provider requires touching dozens of files. For teams building business web applications, this kind of separation makes the difference between a maintainable codebase and one that becomes increasingly difficult to change over time.

Well-designed service classes also make it easier to add retry logic, logging, error handling, and fallback behaviour in one place rather than duplicating it across every API call. You can read more about API design principles for business applications in this guide to simple API design for business applications.

Application-Wide Configuration and State

A configuration class or service container that holds your application settings, database connections, and registered services is an appropriate use of shared state. The alternative, using global variables or passing config arrays through every constructor, creates problems that become harder to manage as the application grows.

The key is that this state should be managed in one place and accessed through a consistent interface. A well-designed configuration service lets you change settings in one location without hunting through your codebase. It also makes testing easier because you can swap configuration values without modifying global state.

Business Logic That Has Internal State

A shopping cart is the classic example. The cart has items in it, the items can be added and removed, the total changes as items are added, and the cart persists across page requests. This kind of stateful, behaviour-rich object is exactly what classes are designed for.

When deciding whether something belongs as a class, consider whether it has internal state that changes over time and behaviour that operates on that state. A user session, an shopping basket, an invoice with line items, and a document editor are all natural candidates for classes because they combine data and behaviour in ways that would be awkward to express as separate functions.

For more complex scenarios where multiple entities interact, it helps to understand how to combine objects effectively. You can explore the differences between inheritance and composition in PHP in this article on PHP OOP inheritance versus composition.

When Classes Are Unnecessary Complexity

Simple Data Transformations

If all you are doing is transforming data from one format to another, a function is clearer than a class. A function that takes an array of database rows and returns an array of formatted HTML table rows does not need to be a method on a TableBuilder class. A well-named function does the job and can be understood in a single glance.

// Clear and simple
function format_rows_as_html(array $rows): string {
    $html = '<table>';
    foreach ($rows as $row) {
        $html .= '<tr><td>' . htmlspecialchars($row) . '</td></tr>';
    }
    return $html . '</table>';
}

Adding a class here would introduce indirection without providing any benefit. The function name is descriptive, the logic is self-contained, and there is no shared state to manage.

Single-Use Behaviour

If a piece of logic is only used in one place and is not likely to be reused or extended, writing it as a class adds indirection without benefit. Write it as a function or a block of code in the relevant file. You can always refactor it into a class later if the need arises.

The YAGNI principle applies to classes as much as to features. You Are Not Going To Need It. Resist the temptation to build abstractions for hypothetical future requirements. Write the code you need now, and refactor when the actual need emerges.

Procedural Data Processing

A script that fetches data, transforms it, and outputs it can often be written clearly as a sequence of function calls. If the script is read by someone who understands PHP, they can follow the flow without understanding any class hierarchy. This is particularly important for maintenance: the person debugging the script during an incident needs clarity, not abstraction.

Data Transfer Objects Without Behaviour

A data transfer object that simply holds properties with no behaviour is often better as an array or a stdClass, unless you specifically need type hinting or IDE autocompletion for those properties. PHP 8.0 introduced named arguments and constructor property promotion, which can reduce the boilerplate needed for simple data containers when you do decide to use a class.

However, PHP 8.3 added readonly classes, which are particularly useful when you need immutable data containers that should not change after construction. If you are working with PHP 8.3 or later and need DTOs that represent stable data, readonly classes can provide useful guarantees without requiring manual setter prevention.

When to Refactor to Classes

The right time to introduce a class is when the complexity of the code exceeds what a function can clearly express, or when you find yourself copying the same function into multiple files. The signal that you need a class is when the function becomes difficult to name without a qualifier.

Imagine you have functions called formatRowsAsHtmlForInvoice and formatRowsAsHtmlForReport. These are variations of the same concept that should probably be a single configurable class. When your function names start accumulating qualifiers like ForInvoice or ForReport, that is often a sign you need an abstraction layer.

A practical checklist for when to introduce a class:

  • Multiple call sites: The same logic is used in more than one place and copying it would create duplication that is difficult to maintain.
  • Shared state: Multiple parts of the application need to share and persist state between calls.
  • Interface boundary: You need to swap an implementation without changing calling code, such as switching payment providers or caching layers.
  • Testability: You need to test the logic in isolation without the side effects of the real implementation.
  • Named responsibility: A clear name for the class expresses what it does better than a function name would.

Interfaces and Dependency Injection

Once you start using classes, you need to think about how they connect to each other. An interface defines a contract: this class will implement these methods with these signatures. Any class that implements the interface can be substituted without changing the code that uses it. This is the basis of dependency injection and makes your code significantly easier to test.

interface InvoiceRepositoryInterface {

    public function find(int $id): ?Invoice;

    public function save(Invoice $invoice): void;

    public function findByClient(int $clientId): array;
}

class DbInvoiceRepository implements InvoiceRepositoryInterface {

    public function __construct(private PDO $db) {}

    public function find(int $id): ?Invoice {
        $stmt = $this->db->prepare('SELECT * FROM invoices WHERE id = ?');
        $stmt->execute([$id]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ? Invoice::fromRow($row) : null;
    }

    public function save(Invoice $invoice): void {
        // implementation
    }

    public function findByClient(int $clientId): array {
        $stmt = $this->db->prepare(
            'SELECT * FROM invoices WHERE client_id = ? ORDER BY created_at DESC'
        );
        $stmt->execute([$clientId]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}

Instead of having your code instantiate its own dependencies, pass them in from outside. This is dependency injection. It makes your code flexible because the same class can work with different implementations of its dependencies.

class InvoiceService {

    public function __construct(private InvoiceRepositoryInterface $repository) {}

    public function getInvoice(int $id): Invoice {
        return $this->repository->find($id);
    }

    public function getClientInvoices(int $clientId): array {
        return $this->repository->findByClient($clientId);
    }
}

In a test, you can pass a mock InvoiceRepository without touching the real database. In production, you pass a real DbInvoiceRepository. The InvoiceService does not know or care where its data comes from. It only knows it can ask the repository for data.

This separation is particularly valuable when building business applications where reliability and testability matter. You want to be able to test your business logic without depending on external services or databases.

Refactoring Procedural Code to Classes

When you inherit a PHP project that is entirely procedural, the question of when to refactor to OOP arises. The answer is: when you need to change something and the change is easier if the code is organised into classes, refactor at that point. The goal is to make the code easier to work with, not to refactor for the sake of refactoring.

A common pattern for gradual refactoring is the service class approach. You create a class that wraps the existing functions as static methods initially, then gradually convert the static methods to instance methods with dependencies injected as you test them. This preserves the existing function interfaces while giving you a class structure to work within.

// Before: procedural
$db = get_db();
$users = fetch_users($db, $role);

// After: class-based refactoring
class UserRepository {

    private PDO $db;

    public function __construct(PDO $db) {
        $this->db = $db;
    }

    public function findByRole(string $role): array {
        $stmt = $this->db->prepare('SELECT * FROM users WHERE role = ?');
        $stmt->execute([$role]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}

The key principle is to refactor incrementally. Do not rewrite the entire application at once. Make small changes, test each change, and build up the object-oriented structure over time as you work with the code.

Signs You Are Using Classes Incorrectly

Classes With Only Static Methods

A class that has only static methods and no instance state is better as a collection of functions. Static methods cannot be mocked for testing, they cannot be swapped for different implementations, and they often indicate that the author wanted to use OOP syntax without the corresponding benefits.

God Objects

A class that is instantiated once and used for everything, that tries to encapsulate all your application logic, is usually a sign that the design has not been properly decomposed. These classes grow over time as developers add new features to the existing class rather than creating new ones.

Classes With Too Many Methods

If you find yourself writing a class with more than ten methods, ask whether all those methods belong together. Classes with a single clear responsibility are easier to test and reuse. If a class is doing many different things, it should be split into smaller classes.

A good heuristic is that if you cannot describe what a class does in one sentence without using the word "and", the class probably has too many responsibilities. A UserService that handles authentication, profile updates, password resets, and email preferences is doing too much. Split it into AuthService, UserProfileService, and NotificationService.

Testing Object-Oriented PHP Code

One of the practical benefits of using classes with dependency injection is that your code becomes easier to test. When a class receives its dependencies through its constructor, you can pass mock objects in your tests instead of the real implementations.

For database access, this means you can test your service classes without needing a test database. For external APIs, this means you can test how your code handles different responses without making actual API calls. For configuration, this means you can test different settings without modifying global state.

PHPUnit and Pest are the most common testing frameworks for PHP. Both support mocking objects and creating test doubles that stand in for your real dependencies.