The Visitor Pattern in PHP: Cleanly Separating Operations from Data Structures
AI generated
<?php
8.4
PHP · OOP Patterns · Visitor Pattern · Double Dispatch
The Visitor Pattern in PHP
Cleanly Separating Operations from Data Structures

The visitor pattern separates operations from the class hierarchy they operate on, so new operations can be added without changing existing data classes. Using an abstract syntax tree as an example, this article shows double dispatch, the visitable interface, and the limits of this approach in PHP 8.4.

18 min read Double Dispatch · Visitable · AST PHP 8.2 · 8.3 · 8.4

1. What problem the visitor pattern solves

The visitor pattern solves a problem that shows up with fixed but widely used class hierarchies: how do you add new operations that need to behave differently for every class in the hierarchy, without changing every involved class each time a new operation appears? A typical example is an abstract syntax tree, or AST, with node types like number, addition, and multiplication. Several independent operations are supposed to run on this AST: evaluation to a concrete numeric value, rendering as a readable string, optimization by simplifying expressions.

Without the visitor pattern, every one of these operations would migrate as a method directly into the node classes, so NumberNode, AdditionNode and MultiplicationNode would each get an evaluate(), a toString() and an optimize() method. Every new operation would mean touching every node class, a clear violation of the Open-Closed principle, since existing code has to be changed for new requirements instead of only being extended.

The visitor pattern reverses this relationship: every operation is implemented as a standalone visitor class, the node classes themselves stay unchanged and only get a single additional method, accept(), which never needs to be touched again. New operations from then on appear as new visitor classes, without a single existing node type being changed.

2. Double dispatch: the technical core of the visitor pattern

The technical trick behind the visitor pattern is called double dispatch. PHP, like most object-oriented languages, only supports single dispatch: which method actually executes on a call depends solely on the runtime type of the object the method is called on, not on the type of a passed argument. This is not enough for the visitor pattern, because both the type of the node and the type of the visitor need to determine the actually executed behavior at the same time.

Double dispatch simulates this behavior with two consecutive method calls. First, the caller calls $node->accept($visitor), this first call dispatches based on the node type. Inside accept(), the node then calls $visitor->visitNumberNode($this), where the concrete method name depends on the concrete node type. This second call dispatches based on the visitor type. Together, both calls produce the combination of node type and visitor type that single dispatch alone cannot deliver.


<?php

declare(strict_types=1);

/**
 * Visitable interface: every node type must accept a visitor.
 */
interface NodeInterface
{
    public function accept(NodeVisitorInterface $visitor): mixed;
}

/**
 * Visitor interface: one method per concrete node type (double dispatch).
 */
interface NodeVisitorInterface
{
    public function visitNumberNode(NumberNode $node): mixed;
    public function visitAdditionNode(AdditionNode $node): mixed;
}

These two interfaces form the basic framework of the visitor pattern. NodeInterface ensures every node can be visited, NodeVisitorInterface defines exactly one method per node type. Double dispatch only emerges from the interplay of both interfaces, neither one alone is enough.

3. Basic structure: visitable interface and visitor interface

The concrete node classes implement NodeInterface and thus the accept() method. This method is almost identical for every concrete node type, it merely calls the matching method for this node type on the visitor and passes itself as the argument. It is important that accept() never contains domain logic, only forwarding to the appropriate visitor method name.


<?php

declare(strict_types=1);

final class NumberNode implements NodeInterface
{
    public function __construct(
        public readonly float $value,
    ) {
    }

    public function accept(NodeVisitorInterface $visitor): mixed
    {
        return $visitor->visitNumberNode($this);
    }
}

final class AdditionNode implements NodeInterface
{
    public function __construct(
        public readonly NodeInterface $left,
        public readonly NodeInterface $right,
    ) {
    }

    public function accept(NodeVisitorInterface $visitor): mixed
    {
        return $visitor->visitAdditionNode($this);
    }
}

Both accept() implementations are deliberately kept trivial, they do nothing beyond forwarding the call to the right visitor method. All the domain logic of an operation, whether evaluation, printing, or optimization, lives exclusively in the visitor implementations, not in the node classes themselves.

4. Practical example: evaluating an abstract syntax tree

A concrete visitor implements NodeVisitorInterface and defines, for every node type, what should actually happen for that node. An EvaluatingVisitor computes the numeric value of the expression tree by moving recursively through the structure: for an AdditionNode it calls accept() on both child nodes and adds the results. For a NumberNode it simply returns the stored value.


<?php

declare(strict_types=1);

/**
 * Concrete visitor: evaluates the expression tree to a numeric result.
 */
final class EvaluatingVisitor implements NodeVisitorInterface
{
    public function visitNumberNode(NumberNode $node): float
    {
        return $node->value;
    }

    public function visitAdditionNode(AdditionNode $node): float
    {
        $left = $node->left->accept($this);
        $right = $node->right->accept($this);

        return (float) $left + (float) $right;
    }
}

/**
 * Concrete visitor: renders the expression tree as a readable string.
 */
final class PrintingVisitor implements NodeVisitorInterface
{
    public function visitNumberNode(NumberNode $node): string
    {
        return (string) $node->value;
    }

    public function visitAdditionNode(AdditionNode $node): string
    {
        return sprintf(
            '(%s + %s)',
            $node->left->accept($this),
            $node->right->accept($this),
        );
    }
}

// (3 + (4 + 5))
$tree = new AdditionNode(
    new NumberNode(3),
    new AdditionNode(new NumberNode(4), new NumberNode(5)),
);

echo $tree->accept(new EvaluatingVisitor()); // 12
echo $tree->accept(new PrintingVisitor());   // (3 + (4 + 5))

Both visitors operate on exactly the same tree structure, without the node classes themselves knowing anything about evaluation or text rendering. That is exactly the core of the visitor pattern: operations and data structure remain fully decoupled, every new operation is a new visitor, no change to NumberNode or AdditionNode.

5. Adding new operations without changing existing classes

The real payoff of the visitor pattern shows up once a third, fourth, or fifth operation comes along. An OptimizingVisitor that folds constant additions at compile time, or a DepthCountingVisitor that determines the maximum nesting depth of the tree, can be added as completely new classes. Neither NumberNode nor AdditionNode nor any existing visitor class needs to be touched for this.

This property makes the visitor pattern particularly valuable in areas with frequently changing or growing requirements for operations but a comparatively stable data structure. Compilers and interpreters are the classic application area, because an AST typically stays stable over a project's lifetime, while the number of operations meant to run on it grows over time: type checking, optimization, code generation, formatting, linting.

6. The extension problem: new node types are expensive

The visitor pattern has a mirror-image downside to its biggest advantage. While new operations are cheap, new node types are expensive. Adding a new node type, for example MultiplicationNode, requires extending NodeVisitorInterface with a new method visitMultiplicationNode(), and every single existing visitor implementation has to add this new method, or its implementation of the interface fails.

This property is known as the expression problem: you can either easily add new operations or easily add new data types, but not both simultaneously without compromises. The visitor pattern deliberately chooses easily extensible operations at the cost of hard to extend data types. Before adopting the visitor pattern, it is therefore worth asking which of the two dimensions, operations or node types, actually grows more often in a given project.

7. Alternative in PHP: a match expression instead of the classic visitor

Since version 8.0, PHP offers a leaner alternative to the classic visitor pattern with the match expression, at least for cases with a manageable number of node types. Instead of implementing double dispatch through accept() and a separate visitor interface, an operation can react directly with match on the node's class name. This saves the interface definitions but forgoes the static guarantee, enforced by PHPStan, that every node type is really handled, unless you add an explicit default branch that throws an exception on unknown types.


<?php

declare(strict_types=1);

/**
 * Lightweight alternative to the classic Visitor: match on ::class instead
 * of implementing accept() and a separate visitor interface.
 */
function evaluate(NodeInterface $node): float
{
    return match ($node::class) {
        NumberNode::class => $node->value,
        AdditionNode::class => evaluate($node->left) + evaluate($node->right),
        default => throw new LogicException('Unknown node type: ' . $node::class),
    };
}

$tree = new AdditionNode(new NumberNode(3), new NumberNode(4));
echo evaluate($tree); // 7

This match-based alternative is often more pragmatic than the full visitor pattern with separate interfaces for small, project internal ASTs. As soon as several independent operations are maintained by several developers, though, or the number of node types grows, the classic visitor pattern gains value through the completeness of the visitor interfaces enforced by PHPStan, because a forgotten node type then becomes a compile time error instead of a runtime exception.

8. Common mistakes with the visitor pattern

The most common mistake is accidentally moving domain logic into accept() after all, instead of keeping it exclusively in the visitor. As soon as accept() does more than forward the call, the clean separation between data structure and operation that justifies the whole pattern is lost. A second mistake is defining the return type of accept() and the visitor methods too narrowly or too broadly. mixed as a return type works for heterogeneous operations like evaluation and text rendering, but gives up type safety when a concrete operation always returns the same, known type.

A third mistake concerns recursive structures without a termination condition. If a tree accidentally contains a cycle, for example because a node mistakenly references itself, a recursive visitor leads to a stack overflow, without the error message revealing the actual cycle in the data model. For trees originating from external sources like parsers, a defensive check for cycles pays off before unleashing a visitor on them.

9. Visitor pattern compared with alternatives

The choice between the classic visitor pattern and leaner alternatives depends on the stability of the node types and the number of operations. The following table compares the common approaches.

Approach New operation New node type PHPStan safety
Methods on node classes Change every class New class is enough Good, but violates Open-Closed
Classic visitor pattern New visitor class Change every visitor Completeness enforced
match on ::class New function Check every match block Only with explicit default
instanceof chain New function Check every chain Weak, no completeness check

For stable data structures with a growing number of operations, especially in compilers, interpreters, and code analysis tools, the classic visitor pattern remains the most robust choice, because PHPStan reliably reports an error for missing interface methods. For small, project internal ASTs with few, stable operations, the match-based alternative is often the more pragmatic path.

Mironsoft

PHP architecture, object design and maintainable backend systems

Planning a compiler, interpreter or code analysis tool in PHP?

We design clean AST structures with the visitor pattern, so new operations like optimization, validation and code generation can be added without changing existing node classes.

AST design

Build a clean node hierarchy and visitable interfaces

Visitor implementation

Evaluation, optimization and code generation as separate visitors

PHPStan safety

Guarantee completeness of every visitor implementation

10. Summary

The visitor pattern separates operations from the class hierarchy they operate on using double dispatch through accept() and a visitor interface with one method per node type. New operations appear as new visitor classes, without any existing node type needing to change, a direct win for the Open-Closed principle. The price is the expression problem: new node types require changes to every existing visitor implementation.

In PHP 8.4, the match expression offers a leaner alternative for small, stable sets of nodes, but forgoes the completeness check enforced by PHPStan that the classic visitor pattern brings automatically. For compilers, interpreters, and code analysis tools with a growing number of operations, the classic visitor pattern remains the more robust choice.

The Visitor Pattern in PHP — Key Takeaways

Double dispatch

Two method calls, accept() and the matching visit method, combine node type and visitor type.

New operations

Appear as a new visitor class, without touching existing node classes.

Expression problem

New node types require changes to every existing visitor. Not a free lunch.

Alternative

match on ::class is more pragmatic for small, stable node sets, but less type safe.

11. FAQ: Visitor Pattern in PHP

1What is double dispatch?
Two method calls, accept() and a visit method, combine node type and visitor type to decide behavior.
2Why isn't single dispatch enough?
PHP decides only based on object type, not on an argument. Visitor needs both types at once.
3What is the expression problem?
Either easy new operations or easy new data types, not both simultaneously without compromises.
4Domain logic in accept()?
No, accept() only forwards. Otherwise the separation between data structure and operation is lost.
5Is match enough instead?
For small node sets yes, but without the completeness guarantee enforced by PHPStan.
6New node type, what happens?
Every visitor implementation needs a new method, otherwise the interface is not fulfilled.
7Typical use case?
Compilers, interpreters, code analysis tools with an AST, stable node types, growing operation count.
8Process recursive structures?
Yes, as long as cycle-free. A cycle causes a stack overflow in the recursive visitor.
9Which return type?
mixed for heterogeneous operations, a specific type when the operation always returns the same type.
10Worth it for small scripts?
Usually not, pays off only with several independent operations on a stable data structure.