Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Recursion Context Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

1. Cycle-safe logging context (the highest-value pattern)

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.

2. Custom PHPUnit constraints and assertions

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.

3. Guards in Artisan commands and queued jobs

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.

4. Service container wiring — usually don't

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.

5. Compose with Laravel's JsonResource / API responses

When 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.

Gotchas and Tips

PHP version ceiling is the real install decision

The release line is pinned to PHP minors aggressively:

  • 8.x requires PHP 8.4+
  • 7.x requires PHP 8.3+
  • 6.x requires PHP 8.2+
  • 5.x requires PHP 8.1+

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 invisible

Context is backed by an object-id set. Two consequences:

  • A self-referencing array built with references ($a['self'] = &$a;) is not caught. You will still blow the stack.
  • Scalars, resources, and closures-as-values are your responsibility.

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*';
    }
    // ...
}

Order matters: 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 reuse

If 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 alive

The 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.

It's a transitive dependency of PHPUnit — treat it as semi-internal

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).

Extension points: none

There is no interface, no visitor hook, no pluggable strategy. The only extension point is your own wrapper — which you want anyway, because:

  • You'll want the depth limit baked in.
  • You'll want array-reference handling.
  • You'll want a stable output format you control.

Treat Context as a primitive and put a thin, project-owned class in front of it.

Debugging unexpected recursion markers

  1. Confirm you're calling contains() before add().
  2. Confirm the Context isn't shared across two logical operations — add a temporary var_dump(spl_object_id($value)) next to the check.
  3. If you're seeing cycles the domain doesn't have, check whether the object was previously traversed and the context was never reset.
  4. Step back: if the goal is human-readable output, dump()/dd() (VarDumper) already solves recursion better than this. Use recursion-context when the output must be data, not text.

When to skip it entirely

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core