Evaluating Heroku alternatives? Start here

Blog

The pattern-first trap

PHP 8.4 and 8.5 give us property hooks, enums, match expressions, and the pipe operator. Here's why these features make many classic design patterns unnecessary, and what to use instead.

ยทby Steve McDougall

I remember when I first discovered design patterns. Suddenly, every problem had a Factory, every conditional needed a Strategy, and my diagrams looked like something out of an enterprise architecture textbook.

My code was "clean" by some abstract measure, but it took three files to do what should have taken three lines. Sound familiar?

If you've spent any time in PHP development, you've probably gone through this phase. I call it the "Junior Architect" phase, where every project starts with a diagram and ends with an AbstractFactoryDecoratorManager. We've all been there. We read the Gang of Four book, we absorbed the SOLID principles, and we went to work implementing every pattern we could name.

But here's the thing. PHP in 2026 is not the PHP we learned these patterns for.

With PHP 8.4's property hooks and asymmetric visibility, PHP 8.5's pipe operator, and the continued refinement of enums and the match expression, the language itself now handles much of what we used to outsource to design patterns. In a world of AI-assisted coding and high-performance runtimes like FrankenPHP and Swoole, deep abstraction layers are becoming a tax. A performance tax. A cognitive tax. A maintenance tax.

My argument is this: Modern PHP has evolved to the point where many classic patterns have quietly become anti-patterns of over-engineering. The language gives us better tools now. We should use them.

Do we still need SOLID?

Before we talk about patterns, we need to talk about principles. SOLID has been the foundation of object-oriented design for decades. But does it still hold up in modern PHP?

  • Single Responsibility Principle: Still relevant, but the boundaries have shifted. In modern PHP, a class with property hooks can handle validation, normalization, and storage in one place without violating SRP. The "responsibility" is state management, and the language now gives us the tools to express that cleanly.
  • Open/Closed Principle: This is where things get interesting. OCP traditionally pushed us toward inheritance hierarchies and interface abstractions. But PHP's enums with methods and match expressions let us extend behavior without class proliferation. The principle remains valid, but the implementation looks different.
  • Liskov Substitution Principle: Still matters. If you're working with interfaces and polymorphism, LSP keeps you honest. But the question is whether you need that polymorphism in the first place. More on that shortly.
  • Interface Segregation Principle: Less relevant when you're not creating interfaces for everything. When your "strategy" is an enum method and your "factory" is a constructor with promoted properties, ISP becomes a solution to a problem you don't have.
  • Dependency Inversion Principle: Still valuable, but the implementation has changed. DI containers handle the wiring. Constructor property promotion makes dependencies explicit. You can depend on abstractions when you need to, but modern PHP reduces how often you actually need to.

The principles aren't wrong. But their application in 2026 looks nothing like it did in 2006. Let's look at specific patterns and see where this plays out.

Saying "No" to the Factory pattern

The Factory pattern was born from necessity. When object instantiation required complex logic, when dependencies had to be wired together manually, and when constructors were limited, Factories made sense.

Consider the old approach:

readonly class UserFactory
{
    public function __construct(
        private UserRepository $users,
        private EventDispatcher $events,
        private PasswordHasher $hasher,
    ) {}
 
    public function create(string $email, string $password): User
    {
        $user = new User(
            email: $email,
            passwordHash: $this->hasher->hash($password),
        );
 
        $this->users->save($user);
        $this->events->dispatch(new UserCreated($user));
 
        return $user;
    }
}

We're creating a class whose sole purpose is to create another class. That's a lot of ceremony.

In modern PHP, constructor property promotion combined with dependency injection containers handles most of this automatically. If you're using Laravel, Symfony, or any modern framework, the container resolves your dependencies. You don't need a Factory to wire things together.

More importantly, if your "factory logic" is really just "do some things after creating an object," that's not a Factory. That's an Action or a Command Handler.

readonly class CreateUser
{
    public function __construct(
        private UserRepository $users,
        private EventDispatcher $events,
        private PasswordHasher $hasher,
    ) {}
 
    public function execute(CreateUserPayload $payload): User
    {
        $user = new User(
            email: $payload->email,
            passwordHash: $this->hasher->hash($payload->password),
        );
 
        $this->users->save($user);
        $this->events->dispatch(new UserCreated($user));
 
        return $user;
    }
}

The difference is subtle but important. We're not hiding behind the Factory abstraction. We're clearly expressing what this class does: it creates a user. The single-purpose Action pattern communicates intent far better than a generic Factory.

My rule of thumb? Use the "Three-Use Rule." Don't abstract the creation until you actually have three distinct concrete types that need different instantiation logic. Until then, you're solving a problem you don't have.

Saying "No" to the Strategy pattern

The Strategy pattern was a revelation when I first learned it. Need different behaviors at runtime? Create an interface, implement it five times, inject the right one. Beautiful.

Except when you end up with this:

interface DiscountStrategy
{
    public function calculate(float $total): float;
}
 
class NoDiscount implements DiscountStrategy
{
    public function calculate(float $total): float
    {
        return $total;
    }
}
 
class PercentageDiscount implements DiscountStrategy
{
    public function __construct(private float $percentage) {}
 
    public function calculate(float $total): float
    {
        return $total - ($total * ($this->percentage / 100));
    }
}
 
class FixedDiscount implements DiscountStrategy
{
    public function __construct(private float $amount) {}
 
    public function calculate(float $total): float
    {
        return $total - $this->amount;
    }
}
 
class BuyOneGetOneFree implements DiscountStrategy
{
    public function calculate(float $total): float
    {
        return $total / 2;
    }
}

That's four files, four classes, and one interface for what amounts to basic arithmetic. This is what I call "class explosion," and it's the silent killer of codebase navigability.

PHP's native enums and the match expression solve this elegantly:

enum Discount: string
{
    case None = 'none';
    case Percentage = 'percentage';
    case Fixed = 'fixed';
    case BOGO = 'bogo';
 
    public function calculate(float $total, ?float $value = null): float
    {
        return match ($this) {
            self::None => $total,
            self::Percentage => $total - ($total * ($value / 100)),
            self::Fixed => $total - $value,
            self::BOGO => $total / 2,
        };
    }
}
 
// Usage
$discount = Discount::Percentage;
$finalPrice = $discount->calculate(100.00, 15);

Fifteen lines. Same functionality. The logic is small, related, and now lives in one place. If the behavior is contained and the variations are known, keep it in the enum.

Save Strategy for when you truly need runtime polymorphism with complex, stateful behaviors. For simple dispatch logic, match is your friend.

Property hooks vs. getters and setters

PHP 8.4 brought us property hooks and asymmetric visibility, and they fundamentally change how we think about encapsulation.

The old pattern looked like this:

class User
{
    private string $email;
 
    public function getEmail(): string
    {
        return $this->email;
    }
 
    public function setEmail(string $email): void
    {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('Invalid email');
        }
        $this->email = strtolower($email);
    }
}

We created methods not because we needed methods, but because we needed to protect state. The getter does nothing. The setter validates and normalizes. This was the only way.

Now we have property hooks:

class User
{
    public string $email {
        set {
            if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
                throw new InvalidArgumentException('Invalid email');
            }
            $this->email = strtolower($value);
        }
    }
}

The property itself handles validation. We're not hiding behind method abstractions. We're expressing our intent directly.

And asymmetric visibility? Even better:

class Account
{
    public private(set) float $balance = 0.0;
 
    public function deposit(float $amount): void
    {
        $this->balance += $amount;
    }
}

The balance is publicly readable but privately writable. No getter needed. No "Encapsulation for the sake of Encapsulation" pattern.

This shift matters because it reduces the cognitive load. When I read a class, I want to understand its state at a glance. I don't want to hunt through methods to understand what's really a property concern.

The pipe revolution

PHP 8.5's pipe operator (|>) arrived in November 2025, and it changes how we think about data transformation.

The old way often involved creating "Service" classes that were really just wrappers for transformation logic:

class SlugGenerator
{
    public function generate(string $title): string
    {
        $trimmed = trim($title);
        $lowercased = strtolower($trimmed);
        $slugified = str_replace(' ', '-', $lowercased);
        $cleaned = preg_replace('/[^a-z0-9\-]/', '', $slugified);
 
        return $cleaned;
    }
}

Or worse, the Chain of Responsibility pattern with multiple handler classes for what's essentially a data pipeline.

With the pipe operator:

$slug = $title
    |> trim(...)
    |> strtolower(...)
    |> (fn($s) => str_replace(' ', '-', $s))
    |> (fn($s) => preg_replace('/[^a-z0-9\-]/', '', $s));

Each step is explicit. The data flows left to right, top to bottom. There's no jumping between files to understand what happens at each stage.

This matters for AI-assisted development too. When you ask an AI to debug a pipeline, the linear flow is immediately comprehensible. Ask it to debug a Chain of Responsibility spread across four Handler classes, and you're in for a longer conversation.

The pipe operator encourages functional thinking over structural thinking. Instead of asking "what class should this belong to?" we ask "what transformation does this data need?"

When to say "Yes"

I'm not advocating for abandoning design patterns entirely. That would be just as dogmatic as over-applying them.

Here's my checklist for when a pattern is worth the abstraction:

  • True Polymorphism: When the behavior genuinely must change at runtime based on external factors you cannot predict. Payment gateways, notification channels, and authentication providers are classic examples. You don't know at compile time which implementation you'll need.
  • External Contract: When you're building a package or library and need to provide stable extension points. Your consumers need to implement your interfaces. The abstraction serves a purpose beyond your own codebase.
  • High-Level Orchestration: When the "simple" approach would create a 500-line file that does too many things. Sometimes patterns exist to separate concerns, not to add indirection. If your Action class is becoming a God object, that's a signal to decompose.

If your use case doesn't fit these criteria, pause before reaching for the pattern. The language might already have your back.

The seniority of simplicity

There's a progression I've observed in developer careers.

Junior developers write simple code because they don't know the patterns. They solve problems directly, sometimes messily, but their solutions work.

Mid-level developers learn patterns and apply them everywhere. They refactor simple code into abstract hierarchies because that's what "professional" code looks like. They measure quality by the sophistication of their architecture.

Senior developers write simple code because they've learned when patterns add value and when they add noise. They've maintained codebases where every change required touching eight files. They've debugged systems where indirection hid bugs. They've earned the confidence to say "no" to complexity.

The goal isn't to know the patterns. The goal is to know when not to use them.

Modern PHP gives us the tools to write expressive, safe, maintainable code without reaching for heavy abstractions. Property hooks give us encapsulation without getters. Enums give us type-safe variants without class hierarchies. The pipe operator gives us composition without handler chains.

The best code is the code you didn't have to write. And the best architecture is the one that lets you solve problems directly, using the language's strengths rather than fighting against its grain.

The next time you reach for a Factory or a Strategy, ask yourself: is this pattern solving a real problem, or am I just pattern-matching because that's what I was taught?

The answer might surprise you.

Deep dive into the cloud!

Stake your claim on the Interwebz today with Sevalla's platform!
Deploy your application, database, or static site in minutes.

Get started