Migrating Procedural PHP to Object-Oriented Code
AI generated
<?php
8.4
PHP · Legacy · OOP Migration · Refactoring
Migrating Procedural PHP to Object-Oriented Code
From function collections to maintainable classes

Many grown PHP projects consist of hundreds of free functions loosely held together by include files. This article shows how to migrate such procedural codebases to object-oriented PHP step by step, using recognizable extraction patterns instead of a risky full rewrite.

19 min read Function groups · Extract classes · Encapsulate state PHP 8.x · Legacy migration

1. Why procedural PHP hits its limits

Procedural PHP is not bad by nature, for small scripts with a clear flow it is often the fastest solution. The problem arises once such a script grows over years and functions start calling each other, sharing state through global variables and living scattered across include files, with no clear structure remaining visible. That is exactly the point where migrating to object-oriented PHP starts to pay off, because classes bring structure, encapsulation and namespaces that plain functions do not offer.

The typical pain point shows up when onboarding new developers: in a procedural codebase with hundreds of functions in a flat namespace hierarchy, it is unclear which function belongs to which business area and what data it actually needs. A function like calculate_shipping_cost($order) often quietly accesses a dozen global variables that never appear in its signature. This invisibility of dependencies is the real reason procedural PHP becomes unmaintainable in large projects, not the syntax itself.

Migrating to object-oriented code does not solve this problem by magic, it makes it visible and workable: a class with typed constructor parameters forces every dependency to be named explicitly. The rest of this article shows how to go from a collection of free functions to a set of coherent classes without bringing the application down during migration.

2. Recognizing function groups as hidden classes

The first step of any migration is not a code change, it is analysis: which functions in the existing codebase repeatedly operate on the same data? A group of functions that all take an $order array as their first parameter and mutate it is really a class that has not been recognized as such yet. This observation, often called a "data clump", is one of the most reliable signals for a meaningful class extraction.

In practice such groups are found by scanning function names for shared prefixes, such as order_calculate, order_validate, order_cancel. This naming pattern is usually not a coincidence, it is an informal class that the language itself never captured. A useful tool for this analysis is a simple script that extracts all function definitions of a file via regex and groups them by shared prefix, giving a first overview of possible classes before a single line of code changes.


<?php
// Legacy procedural functions — all operate on the same $order array
// This is a hidden class that has not been recognized as such yet

function order_total(array $order): float
{
    $sum = 0.0;
    foreach ($order['lines'] as $line) {
        $sum += $line['price'] * $line['quantity'];
    }
    return $sum;
}

function order_is_ready_to_ship(array $order): bool
{
    return $order['status'] === 'paid'
        && !empty($order['shipping_address']);
}

function order_shipping_cost(array $order): float
{
    $weight = array_sum(array_column($order['lines'], 'weight'));
    return $weight > 5.0 ? 9.90 : 4.90;
}

// Somewhere else in the codebase, the same array is passed around
// with no guarantee about its shape or invariants
$order = ['lines' => [], 'status' => 'new', 'shipping_address' => null];
$total = order_total($order);

All three functions operate on the same array without any shared structure or validation. Each function must check itself whether the expected keys even exist, leading to repeated, defensive code. That repetition is exactly the signal that $order should become a real class.

3. Extracting the first class: a practical example

Extraction begins by defining a class that maps the previous array keys to typed properties, and methods that take over the body of the previous functions. It is important to keep the method as close to the original behavior as possible at first, instead of improving business logic at the same time. Separating behavior changes from structural changes is the core of safe refactoring, mixing the two increases the risk of introducing a bug unnoticed.

In the following example, the three free functions become a class Order, whose constructor gathers the previously loosely held data and whose methods carry out the exact same business logic as before, just now encapsulated and with a guaranteed data structure.


<?php

declare(strict_types=1);

final class OrderLine
{
    public function __construct(
        public readonly float $price,
        public readonly int $quantity,
        public readonly float $weight,
    ) {
    }
}

// Extracted class replaces the loosely structured $order array
final class Order
{
    /** @param OrderLine[] $lines */
    public function __construct(
        private readonly array $lines,
        private readonly string $status,
        private readonly ?string $shippingAddress,
    ) {
    }

    public function total(): float
    {
        $sum = 0.0;
        foreach ($this->lines as $line) {
            $sum += $line->price * $line->quantity;
        }
        return $sum;
    }

    public function isReadyToShip(): bool
    {
        return $this->status === 'paid' && $this->shippingAddress !== null;
    }

    public function shippingCost(): float
    {
        $weight = array_sum(array_map(
            static fn (OrderLine $line): float => $line->weight,
            $this->lines
        ));
        return $weight > 5.0 ? 9.90 : 4.90;
    }
}

$order = new Order(
    lines: [new OrderLine(price: 19.99, quantity: 2, weight: 0.5)],
    status: 'paid',
    shippingAddress: '1 Sample Street, 12345 Berlin',
);
echo $order->total();

The decisive difference from the procedural version is not functionality, it is the guarantee: an Order object cannot exist in an inconsistent intermediate state, because the constructor requires all necessary data. With the array version, any caller could accidentally forget a key without PHP ever noticing at development time.

4. Encapsulating implicit state in properties

Besides function groups operating on arrays, procedural code often contains implicit state via static function variables or files acting as a primitive database. This state is particularly treacherous because it persists between calls without that being visible in the function name. A function next_order_number() that internally increments a static variable behaves differently on every call, even though the signature shows no parameters.

The object-oriented equivalent is a class with a private property that holds the state explicitly, whose instance is deliberately created where the state is needed. That turns invisible, globally shared state into locally controlled state whose lifetime is visible through the object instance. This step, turning implicit into explicit state, is often more valuable than merely renaming functions into methods, because it eliminates the actual source of error.


<?php
// Legacy: implicit state hidden inside a static function variable
function next_order_number(): int
{
    static $counter = 1000;
    return $counter++;
}

declare(strict_types=1);

// Object-oriented equivalent: state is explicit, lifetime is visible
final class OrderNumberGenerator
{
    public function __construct(
        private int $counter = 1000,
    ) {
    }

    public function next(): int
    {
        return $this->counter++;
    }
}

// Two independent generators, no shared hidden state between them
$generatorA = new OrderNumberGenerator();
$generatorB = new OrderNumberGenerator(counter: 5000);
echo $generatorA->next(); // 1000
echo $generatorB->next(); // 5000

5. Replacing associative arrays with objects

Associative arrays are the universal tool for structured data in procedural PHP, but they offer no guarantees whatsoever: a typo in a key only becomes visible at runtime, often as a silent null return instead of an error. Migrating to objects solves this structurally, because PHPStan or Psalm already detect at development time when a non-existent property is accessed, while a typo in an array key goes unnoticed.

A proven intermediate step in larger migrations is to convert only the most frequently used arrays into value objects first, while less commonly used structures remain arrays for now. This prioritization by usage frequency ensures that most of the type safety gain is achieved with the least migration effort, instead of rebuilding all arrays simultaneously with equal priority.

6. Backward-compatible facades for old callers

A migration that requires all callers of a function to switch to the new class at once is rarely practical in large projects. The proven approach is to keep the original function as a thin facade that internally instantiates the new class and calls its method. That way old code keeps working unchanged while new code already works directly with the class.


<?php

declare(strict_types=1);

// Backward-compatible facade: old callers keep working unchanged
function order_total(array $order): float
{
    trigger_error(
        'order_total() is deprecated, use Order::total() instead',
        E_USER_DEPRECATED
    );

    $lines = array_map(
        static fn (array $item): OrderLine => new OrderLine(
            price: (float) $item['price'],
            quantity: (int) $item['quantity'],
            weight: (float) ($item['weight'] ?? 0.0),
        ),
        $order['lines'] ?? []
    );

    $orderObject = new Order(
        lines: $lines,
        status: $order['status'] ?? 'new',
        shippingAddress: $order['shipping_address'] ?? null,
    );

    return $orderObject->total();
}

The E_USER_DEPRECATED notice makes it visible how often the old function is still called, without interrupting operations. Combined with a log handler that collects such messages centrally, this becomes an honest, measurable metric for migration progress, without any expensive manual code search.

7. The right order: bottom-up instead of top-down

A common mistake in migration is starting with the largest, most central function because it promises the greatest perceived benefit. In practice the opposite is more sensible: start with small, leaf-like functions with no dependencies on other function groups, extract those into classes first, and gradually work toward the more central, more interconnected functions.

This bottom-up approach has a practical reason: small, isolated functions can easily be secured with characterization tests before being rebuilt, while central functions with many dependencies carry a significantly higher risk of unintended behavior changes. Migrating the simple cases first also builds experience with the extraction process itself, before the more complex, riskier parts of the codebase are due.

8. Writing characterization tests before migration

Before a function is turned into a class, a test should exist that documents the function's current, actual behavior, regardless of whether that behavior is technically correct. Such characterization tests are not a substitute for full test coverage, they serve a different purpose: they guarantee that the migration does not unintentionally change behavior, even if the original behavior contains edge cases or quirks nobody consciously remembers.

In practice, a simple test that calls the old function with representative inputs and records the output, without stating an explicit expectation but capturing the current result as a golden master, is usually enough. After extracting into the class, the same test runs against the new method, and any deviation from the golden master immediately reveals an unintended behavior change before it becomes visible in production.


<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

// Characterization test: documents current behavior, not required
// correctness — captures the golden master before extraction
final class OrderTotalCharacterizationTest extends TestCase
{
    public function testGoldenMasterBeforeExtraction(): void
    {
        $order = ['lines' => [
            ['price' => 19.99, 'quantity' => 2, 'weight' => 0.5],
        ], 'status' => 'new', 'shipping_address' => null];

        // Golden master value observed from the legacy function,
        // not derived from a spec — just what it currently returns
        self::assertSame(39.98, order_total($order));
    }

    public function testGoldenMasterAfterExtraction(): void
    {
        // Same input, same golden master, now against the new class
        $lines = [new OrderLine(price: 19.99, quantity: 2, weight: 0.5)];
        $order = new Order(lines: $lines, status: 'new', shippingAddress: null);

        self::assertSame(39.98, $order->total());
    }
}

9. Procedural vs. object-oriented compared

The following table contrasts the most important structural differences between procedural and object-oriented PHP based on concrete criteria relevant to a migration decision.

Criterion Procedural Object-oriented
Data consistency Array keys can be missing, no guarantee Constructor enforces complete data
Static analysis Typos in array keys go undetected PHPStan detects invalid property access
Namespace All functions in the global namespace Classes grouped through PSR-4 namespaces
Testability Global dependencies hard to swap Constructor injection enables mocks
Extensibility New cases require if/switch chains New classes pluggable via interfaces

The table is not a blanket license for wholesale rebuilds: small, one-off scripts rarely benefit from a full class structure. For grown, long-lived applications with multiple developers, however, the advantages of object-oriented structures clearly outweigh the effort, especially regarding data consistency and testability.

Mironsoft

PHP legacy modernization and Magento development

Ready to finally turn procedural PHP into maintainable classes?

We identify hidden function groups in your codebase, extract classes step by step, and secure every migration step with characterization tests.

Structure Analysis

Identify function groups and hidden classes in your existing code

Gradual Extraction

Extract classes bottom-up, with backward-compatible facades

Safety Net

Write characterization tests before every migration stage

10. Summary

Migrating from procedural to object-oriented PHP is not a pure syntax exercise, it is a structural improvement: classes make implicit dependencies visible, enforce consistent data through constructors, and enable static analysis that is not possible with arrays and free functions. The starting point of any migration is analyzing existing function groups that operate on the same data and effectively already form a class, even though that class was never named.

The safe path is bottom-up extraction, starting with small, isolated functions, secured by characterization tests that document existing behavior before it is rebuilt. Backward-compatible facades with E_USER_DEPRECATED notices allow old and new code to run in parallel until every caller has migrated. In the end, the codebase has object-oriented PHP that does not feel imposed, but makes the application's actual business structure visible.

Migrating Procedural to Object-Oriented PHP — Key Takeaways

Analysis

Function groups with a shared prefix or shared data parameter are hidden classes.

Extraction

The constructor enforces complete data, methods take over the business logic unchanged.

Order

Bottom-up: small, isolated functions first, central, interconnected functions last.

Safety Net

Characterization tests before every extraction, backward-compatible facades for old callers.

11. FAQ: Migrating Procedural to Object-Oriented PHP

1Is procedural PHP fundamentally bad?
No. For small, short-lived scripts it is often the most fitting solution. Problems arise as size and shared state grow.
2How do I recognize hidden classes?
Functions sharing a data parameter or name prefix usually operate on a business entity that should be extracted.
3Must all callers migrate at once?
No, a backward-compatible facade allows gradual, independent migration of callers.
4Why bottom-up instead of top-down?
Small, isolated functions carry less risk and build experience before more complex parts follow.
5What is a characterization test?
A test documenting current behavior regardless of correctness, to catch unintended changes during migration.
6Should I replace all arrays immediately?
No, prioritize the most frequently used arrays first, leave rare structures as arrays for now.
7How do I measure progress?
E_USER_DEPRECATED in facade functions, logged centrally, gives a shrinking metric of remaining callers.
8Does OOP automatically boost performance?
No, the gain lies in maintainability and type safety, not performance.
9What about functions without a data class?
Stateless helper functions can remain static methods in a utility class, that is usually enough.
10How much time should I plan for?
Usually continuous migration over months alongside feature work, rather than a separate rebuild project.