sebastian/recursion-context
sebastian/recursion-context is a small PHP utility for tracking recursive traversals and avoiding infinite loops when walking nested structures. It provides the RecursionContext class used by sebastian/comparator and PHPUnit to handle recursion safely.
First: check whether you already have it. In a Laravel app you almost certainly do — it arrives transitively through phpunit/phpunit → sebastian/comparator → sebastian/recursion-context. Confirm before adding anything:
composer show sebastian/recursion-context # shows version + "required by"
composer why sebastian/recursion-context
You only need an explicit require when your own code calls the API:
composer require --dev sebastian/recursion-context
Keep it in require-dev unless you're shipping something that inspects arbitrary user data in production (rare — see Gotchas).
Where to look first. The entire public surface is one small class, SebastianBergmann\RecursionContext\Context, with three methods:
| Method | Purpose |
|---|---|
add(object $value) |
Register an object as "already visited" |
contains(object $value): bool |
Has this object been visited in this context? |
reset() |
Clear the visited set |
That's it. There is no traversal engine, no visitor, no callback. It is a visited-set — a guard you consult while writing your own recursion. Read the class before the README; the README is shorter than most changelogs.
First real use case. You have an Eloquent model graph with a bidirectional relation and need to render it somewhere that will explode on recursion — a log context array, an exception payload, a custom test assertion.
use SebastianBergmann\RecursionContext\Context;
class CycleSafeDumper
{
public function dump(mixed $value, ?Context $context = null): mixed
{
$context ??= new Context();
if (is_object($value)) {
if ($context->contains($value)) {
return sprintf('*RECURSION* (%s)', $value::class);
}
$context->add($value);
$out = ['@class' => $value::class];
foreach ((array) $value as $key => $prop) {
// (array) casts Eloquent internals with NUL-prefixed keys
$key = str_contains($key, "\0") ? substr($key, strrpos($key, "\0") + 1) : $key;
$out[$key] = $this->dump($prop, $context);
}
return $out;
}
if (is_array($value)) {
return array_map(fn ($v) => $this->dump($v, $context), $value);
}
return $value;
}
}
Log::error('order graph', (new CycleSafeDumper)->dump($order->load('items.product')));
Without the guard, Order::$items[0]->order === $order turns Log::error into a stack-overflow or a multi-megabyte payload.
Laravel's report() and Log::withContext() happily accept objects; the default normalizer will recurse into models with back-references. Wrap anything that touches a domain graph:
public function report(Throwable $e): void
{
Log::error($e->getMessage(), [
'subject' => (new CycleSafeDumper)->dump($e->getSubject() ?? null),
'user' => auth()->id(),
]);
}
Note this is not a replacement for Symfony\Component\VarDumper (which Laravel's dd()/dump() use and which handles recursion well). Reach for RecursionContext when you need the output as a plain PHP array — for JSON logging, queue payloads, or storage — not for human-readable dumps.
sebastian/comparator already uses this package internally, so plain assertEquals() on recursive graphs works. Where you still need it is custom traversal in your own constraints:
final class NoDuplicateRelationsConstraint extends Constraint
{
private Context $context;
protected function matches($other): bool
{
$this->context = new Context(); // fresh per evaluation, never shared
return $this->walk($other);
}
private function walk(mixed $value): bool
{
if (!is_object($value)) {
return true;
}
if ($this->context->contains($value)) {
return false; // same instance reached twice → duplicate
}
$this->context->add($value);
foreach ((array) $value as $prop) {
if (!$this->walk($prop)) {
return false;
}
}
return true;
}
}
Useful for asserting a service doesn't inject the same mutable collaborator into two places, or that an Eloquent graph has no accidental cross-links.
Anywhere you walk user-supplied or model-supplied structure of unknown depth:
final class InspectPayloads extends Command
{
public function handle(): int
{
DB::table('webhook_events')->lazyById()->each(function ($row) {
$context = new Context(); // per row — one context per root value
$safe = $this->normalize(json_decode($row->payload, true), $context);
// ...
});
return self::SUCCESS;
}
}
Rule of thumb: one Context per root value. Do not hoist it above the loop.
Resist the urge to $this->app->singleton(Context::class). It's mutable state in a long-running worker (Octane, Horizon) and will leak "already visited" across requests. If you must inject it, use scoped():
$this->app->scoped(Context::class, fn () => new Context());
And even then, reset() at the start of each use.
JsonResource / API responsesWhen a resource needs to include a sibling graph that may link back:
return response()->json((new CycleSafeDumper)->dump($node));
This produces *RECURSION* markers instead of json_encode(): Recursion detected errors or JSON_PARTIAL_OUTPUT_ON_ERROR silently returning null.
The release line is pinned to PHP minors aggressively:
Composer will resolve the right line for you, so never hard-pin ^8.0 in composer.json — it will make composer install fail on the PHP 8.2/8.3 runtimes most Laravel apps still run. Let it float (^5.0|^6.0|^7.0|^8.0 if you must be explicit), or omit the constraint entirely and rely on your PHPUnit chain.
contains() tracks objects only — arrays are invisibleContext is backed by an object-id set. Two consequences:
$a['self'] = &$a;) is not caught. You will still blow the stack.So the guard only solves half the problem. Always pair it with a depth limit:
private function walk(mixed $v, Context $ctx, int $depth = 0): mixed
{
if ($depth > 32) {
return '*MAX_DEPTH*';
}
// ...
}
contains() before add()if ($context->contains($value)) { return '*RECURSION*'; }
$context->add($value); // ← must come after the check
Reversing these makes every object look like a cycle on first visit. This is the single most common bug when hand-rolling the pattern.
reset() is mandatory on reuseIf you pass one Context into two independent operations, the second sees everything from the first as "already visited" and emits spurious recursion markers. Symptom: your dumper prints *RECURSION* for a plain, acyclic object on the second test. Fix: new Context() per root, or $context->reset() at the top of each entry point.
SplObjectStorage keeps objects aliveThe implementation holds strong references to everything added. A long traversal over thousands of models will retain them all until the Context is discarded — a memory leak in a lazyById() loop if you hoist the context. This is a further argument for per-row instantiation.
sebastian/comparator and sebastian/exporter depend on it, and a PHPUnit major bump can move the Context major in lockstep. If you call it directly, add it to composer.json explicitly so a composer update can't silently yank it out from under you — and keep the constraint loose across majors (the three-method API has been stable for years).
There is no interface, no visitor hook, no pluggable strategy. The only extension point is your own wrapper — which you want anyway, because:
Treat Context as a primitive and put a thin, project-owned class in front of it.
contains() before add().Context isn't shared across two logical operations — add a temporary var_dump(spl_object_id($value)) next to the check.dump()/dd() (VarDumper) already solves recursion better than this. Use recursion-context when the output must be data, not text.For a one-off, a plain SplObjectStorage or spl_object_id() lookup array is ten lines and zero dependencies. Adopt this package when you want the same semantics PHPUnit uses internally (so your cycle markers line up with test diffs), or when your team already maintains several hand-rolled recursion guards you'd like to collapse into one. Otherwise, be honest with yourself: the value here is standardization, not capability.
How can I help you explore Laravel packages today?