Refactoring If-Else Hell into a Strategy Pattern in PHP ⚙️

Hey there! Today I want to share a common problem I’ve faced many times in PHP projects: a method full of if/else if statements that handles different types of orders. You know the type—huge, unreadable, and almost impossible to extend without breaking something. 😅

In this article, I’ll show you how to refactor such if-else hell into something much cleaner using the Strategy Pattern. By the end, you’ll see how flexible, testable, and maintainable your code can become.

Link to previous article below:

Let’s imagine we have a simple OrderProcessor class:

class OrderProcessor {
    public function process(Order $order) {
        if ($order->type === 'digital') {
            echo "Processing digital order\n";
            // some digital-specific logic
        } elseif ($order->type === 'physical') {
            echo "Processing physical order\n";
            // physical-specific logic
        } elseif ($order->type === 'subscription') {
            echo "Processing subscription order\n";
            // subscription-specific logic
        } else {
            throw new Exception("Unknown order type");
        }
    }
}
PHP

At first glance, it works — and for three stable, simple cases like this, it may be all you need. The problem appears when each branch starts growing its own sub-conditions, or when new types are added every few weeks by different developers.

Problems with this approach — at scale:

  • Adding a new order type requires editing this method → violates Open/Closed Principle.
  • Hard to test individual behaviors without setting up multiple scenarios.
  • Readability decreases as the logic grows.

Note: these problems appear at scale. A three-branch if/else with simple method calls is not inherently wrong — it is direct, traceable, and easy to read. The refactoring below is aimed at the version that has already outgrown that simplicity.

The core problem is not the if/else itself. The problem is that multiple independently changing behaviors are encoded in a single place.

  • Duplicated logic across conditions.
  • High coupling: OrderProcessor knows about every type.
  • Poor testability: you can’t isolate behavior easily.

So what do we want? We want a solution where:

  • Each order type handles its own logic.
  • Adding a new type does not break existing code.
  • The code is easy to test and extend.

Enter the Strategy Pattern.

3️⃣ Before You Refactor: The KISS Check

Before reaching for any pattern, ask yourself: would a colleague unfamiliar with this codebase understand this code in 30 seconds?

php

// This is fine. Don't touch it.
public function process(Order $order) {
    if ($order->type === 'digital') {
        $this->sendDownloadLink($order);
    } elseif ($order->type === 'physical') {
        $this->scheduleShipment($order);
    } elseif ($order->type === 'subscription') {
        $this->activateSubscription($order);
    } else {
        throw new \InvalidArgumentException("Unknown order type: {$order->type}");
    }
}
PHP

Three types, three clear method calls, one place to look. Any developer can open this file and immediately understand what happens for each order type. That has real value.

The Strategy Pattern earns its complexity when branches start looking like this instead:

} elseif ($order->type === 'physical') {
    if ($order->weight > 30) {
        // freight carrier logic
        if ($order->destination === 'international') {
            // customs declaration logic
            // 15 more lines
        }
    } elseif ($order->destination === 'international') {
        // different international logic
    }
    // 40 more lines of physical-specific handling
}
PHP

That’s the moment the if/else stops being a straight line between two points — and the Strategy Pattern starts paying for itself.

First, we create an interface that all order strategies will implement:

interface OrderStrategy {
    public function process(Order $order): void;
}
PHP

This way, every strategy guarantees it can process an Order.

Next, we create a class for each order type:

class DigitalOrderStrategy implements OrderStrategy {
    public function process(Order $order): void {
        echo "Processing digital order\n";
        // digital-specific logic here
    }
}

class PhysicalOrderStrategy implements OrderStrategy {
    public function process(Order $order): void {
        echo "Processing physical order\n";
        // physical-specific logic here
    }
}

class SubscriptionOrderStrategy implements OrderStrategy {
    public function process(Order $order): void {
        echo "Processing subscription order\n";
        // subscription-specific logic here
    }
}
PHP

Each class only handles its own behavior. Notice that this only makes sense because each branch would carry substantial, independently evolving logic — not just a single method call.

Now, we modify OrderProcessor to use these strategies:

class OrderProcessor {
    private array $strategies;

    public function __construct(array $strategies) {
        $this->strategies = $strategies;
    }

    public function process(Order $order) {
        if (!isset($this->strategies[$order->type])) {
            throw new Exception("Unknown order type");
        }

        $this->strategies[$order->type]->process($order);
    }
}
PHP

We pass an array of strategies to the processor. The processor doesn’t care how each strategy works, only that it can process the order.

$processor = new OrderProcessor([
    'digital' => new DigitalOrderStrategy(),
    'physical' => new PhysicalOrderStrategy(),
    'subscription' => new SubscriptionOrderStrategy(),
]);

$order = new Order('digital');
$processor->process($order);
PHP

Adding a new type means creating a new class and registering it here. No existing logic is touched.

One honest trade-off to name explicitly: runtime dispatch means your IDE and static analysis tools cannot tell you which strategy executes for a given order type without running the code. In the simple if/else version, you can trace the entire flow by reading one method top to bottom. That traceability has value — keep it in mind when deciding whether this refactoring is warranted.

One of the biggest advantages: testing becomes simple. Each strategy can be tested in isolation:

class DigitalOrderStrategyTest extends TestCase {
    public function testProcess() {
        $order = new Order('digital');
        $strategy = new DigitalOrderStrategy();

        $this->expectOutputString("Processing digital order\n");
        $strategy->process($order);
    }
}
PHP

Repeat for other strategies!

6️⃣ Benefits — and Their Costs

Every benefit comes with a cost worth naming honestly:

BenefitTrade-off
Open/Closed: new types don’t break existing codeMore files to navigate; runtime registration hides intent from static analysis
Single Responsibility: each class has one reason to changeLogic for one domain is now spread across multiple classes
Testability: strategies are isolated and easy to testEnd-to-end flow is harder to trace without running the code
Extensibility: adding a new type is trivialInitial setup cost is non-trivial

7️⃣ Guidelines for Keeping It Effective

  • Use a Factory if you have many strategies — helps with dependency injection and keeps registration centralised.
  • Keep strategies small — each class should do one thing.
  • Don’t overuse — if you only have 2–3 stable cases with simple logic, a plain if/else is the right choice. Strategy shines with growing, independently changing variability.
  • Document the intent — Strategy is about interchangeable behavior, not just splitting code into smaller files.

When the Strategy Pattern Is the Wrong Choice

The Strategy Pattern introduces more classes, more indirection, and one significant practical cost: runtime dispatch breaks static traceability. Your IDE cannot tell you which strategy executes for a given order type without running the code. In a codebase you need to audit or debug quickly, that matters.

If you only have:

  • two or three simple, stable cases
  • logic that is unlikely to change
  • no need for extensibility

Then a simple conditional is perfectly fine — and arguably better.

In some cases, alternatives may be more appropriate:

  • Associative arrays of callbacks — lighter weight, still avoids the chain
  • PHP 8 match expressions — clean, exhaustive, statically analysable
  • Small command objects — useful when each action carries its own context

Refactoring if/else chains into a Strategy Pattern in PHP is a powerful way to clean up messy code, make it extensible, and improve testability — when the complexity justifies it.

The steps are straightforward:

  1. Identify chains where branches grow independently and carry substantial logic.
  2. Extract each behavior into a separate class implementing a common interface.
  3. Use a context class to delegate work.
  4. Accept the trade-off on static traceability consciously, not accidentally.

Refactoring long if/else chains into the Strategy Pattern is not about elegance — it is about reducing risk in systems that are actively growing.

The next time you encounter a method that keeps growing with every new requirement, it is worth asking two questions:

Is this logic really meant to live in one place?

And equally: is it complex enough to justify living in multiple places?

The goal is not clean patterns — it is code that the next developer can follow without running it in their head first.

Next good source of information about this topic is here https://refactoring.guru/design-patterns/strategy.

Leave a Reply

Your email address will not be published. Required fields are marked *