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.
Table of Contents
- 1. What problem the visitor pattern solves
- 2. Double dispatch: the technical core of the visitor pattern
- 3. Basic structure: visitable interface and visitor interface
- 4. Practical example: evaluating an abstract syntax tree
- 5. Adding new operations without changing existing classes
- 6. The extension problem: new node types are expensive
- 7. Alternative in PHP: a match expression instead of the classic visitor
- 8. Common mistakes with the visitor pattern
- 9. Visitor pattern compared with alternatives
- 10. Summary
- 11. FAQ
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.