hoa/visitor
Hoa\Visitor is a lightweight PHP library for implementing the Visitor design pattern. It provides visitor and visitable interfaces plus base classes, making it easy to add operations to object structures without modifying their classes.
hoa/visitor is a ~200-line library that formalizes the Visitor pattern: a node exposes accept(Visitor $v, &$data = null), and a visitor exposes visit(Element $e, &$data = null). That is essentially the entire surface area — the value is the shared contract, not the implementation.
composer require hoa/visitor
Two things to check before you write any code:
vendor/hoa/visitor/ contains a handful of interfaces (Visit, Visitor, Element) plus an exception class. They are short enough to read in five minutes, and there is no magic dispatcher — you implement the dispatch yourself.hoa/exception and friends, and Composer may warn on composer install. If it fights you, copy the two interfaces into app/Support/Visitor/ and drop the dependency — you lose nothing functionally.First use case in a Laravel app: a heterogeneous tree you already model with polymorphic relations — a CMS block tree, a menu tree, a rules engine, or an AST for a mini DSL. Today you probably have a giant switch (get_class($node)) scattered across three services; the visitor pattern replaces each scattering with one cohesive class.
<?php
namespace App\Documents;
use Hoa\Visitor;
abstract class Node implements Visitor\Visit
{
public function accept(Visitor\Visitor $visitor, &$data = null)
{
return $visitor->visit($this, $data);
}
}
final class Document extends Node
{
/** @param Node[] $children */
public function __construct(public array $children = []) {}
}
final class Heading extends Node
{
public function __construct(public string $text, public int $level = 1) {}
}
final class Paragraph extends Node
{
public function __construct(public string $text) {}
}
1. One dispatcher, many visitors. The library doesn't dispatch, so build a single abstract base that maps element classes to visit{ShortName} methods — that's the "extension point" everyone rebuilds anyway:
namespace App\Documents\Visitors;
use Hoa\Visitor;
abstract class Dispatcher implements Visitor\Visitor
{
public function visit(Visitor\Element $element, &$data = null)
{
$method = 'visit' . class_basename($element);
if (! method_exists($this, $method)) {
throw new \LogicException(
sprintf('No handler [%s] for %s', $method, get_class($element))
);
}
return $this->{$method}($element, $data);
}
}
2. Keep each visitor a single responsibility. An HtmlRenderer, a WordCounter, an OutlineExtractor, and a PlainTextExporter all share the node model, and you can add a new operation without touching a single node class. This is the whole point — it beats instanceof chains the moment you have more than two operations.
class HtmlRenderer extends Dispatcher
{
protected function visitDocument(Document $d, &$data): string
{
return collect($d->children)
->map(fn (Node $c) => $c->accept($this, $data))
->implode("\n");
}
protected function visitHeading(Heading $h, &$data): string
{
return "<h{$h->level}>" . e($h->text) . "</h{$h->level}>";
}
protected function visitParagraph(Paragraph $p, &$data): string
{
return '<p>' . e($p->text) . '</p>';
}
}
3. Resolve visitors through the container so they can have real dependencies (config, repositories, formatters). Bind the concrete visitor in a service provider and inject it into controllers, jobs, or event listeners — the visitor is just a service:
// Controller
public function show(Document $doc, HtmlRenderer $renderer)
{
return view('documents.show', ['html' => $doc->accept($renderer)]);
}
4. Use &$data as a shared traversal context, not as the return value. Return values are for the "computed" result of one node; $data is the accumulator/context shared across the walk (indentation depth, parent path, counters, a SplStack of open tags). It's a reference, so it survives recursion into children cleanly:
$data = ['depth' => 0];
$rendered = $doc->accept($renderer, $data); // $data mutated in place
5. Wire it into Laravel jobs/listeners for expensive walks — e.g. a queued BuildOutline job that hydrates the tree, calls $root->accept(new OutlineVisitor, $data) and persists the result. Visitors are stateless by design, so they're safe to serialize into a job and inject.
6. Test visitors, not nodes. Because dispatch is deterministic, each visitor is a pure unit under test: construct a tiny tree in the test, call accept, assert the string/array. You rarely need a full Laravel test case — plain PHPUnit/Pest is enough.
hoa/visitor as vendored infrastructure. If a future PHP minor breaks the old &$data = null signature, forking two interfaces into app/Support/ is a fifteen-minute fix and eliminates the risk entirely.$node->accept(new HtmlRenderer) will throw a fatal error because the second parameter is &$data. Assign to a variable first ($v = new HtmlRenderer; or app(HtmlRenderer::class)).Paragraph is extended by Quote, class_basename() yields Quote and your visitParagraph is never called. Either keep node hierarchies shallow, or check instanceof / walk parents in the dispatcher. Anonymous classes (new class extends Node) are effectively unusable for this reason.method_exists is your debugger. The most common failure is a typo'd handler name — dump get_class($element) inside visit() the moment something unexpectedly throws LogicException.accept() onto Eloquent models. Making an Article model implement the visitor interface pollutes the persistence layer and couples your schema to presentation logic. Wrap models in small DTO nodes (ArticleNode, CategoryNode) inside the same tree you're already building — the visitor then depends on your domain, not on Eloquent.$data when the tree can be a graph rather than a tree.Hoa\Exception dependency is dead weight. The library's exception class extends Hoa's own base exception; if you fork the interfaces, you can drop the whole hoa/exception subtree from composer.lock as a bonus.match on get_class($node) is clearer, dependency-free, and PHP 8-friendly. Reach for hoa/visitor (or its forked interfaces) only when the number of operations will grow independently of the node set.How can I help you explore Laravel packages today?