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.
Table of Contents
- 1. Why procedural PHP hits its limits
- 2. Recognizing function groups as hidden classes
- 3. Extracting the first class: a practical example
- 4. Encapsulating implicit state in properties
- 5. Replacing associative arrays with objects
- 6. Backward-compatible facades for old callers
- 7. The right order: bottom-up instead of top-down
- 8. Writing characterization tests before migration
- 9. Procedural vs. object-oriented compared
- 10. Summary
- 11. FAQ
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.