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.
Architecture fit
sebastian/recursion-context is a single-purpose, low-level utility: it exposes a Context object that tracks object identities via spl_object_id() so that recursive traversal of PHP variables can detect and short-circuit circular references. It is not a Laravel package — there is no service provider, facade, config, or published asset — and it has no awareness of the framework at all. Architecturally it sits below the application layer, in the tooling/observability space: dumpers, exporters, test assertions, debug middleware, and DTO comparators.
For a Laravel team, the correct framing is that this is not a dependency you adopt as a product decision — it is a dependency you almost certainly already have. It arrives transitively through phpunit/phpunit (via sebastian/comparator, sebastian/exporter, sebastian/diff and friends) in every Laravel project with a standard test suite. The genuine TPM question is therefore not "should we integrate this?" but "do we have a policy for it, do we ever call it directly, and what is our exposure when it drops a PHP version?"
The legitimate direct-use cases are narrow and all internal-facing:
Integration feasibility
Trivially feasible technically — one Composer line, no bootstrapping, no configuration, no database or queue surface. The friction is entirely version-policy friction. Version 8.0.0 removes support for PHP 8.3 and below; 7.0.0 removed PHP 8.2; 6.0.2 swapped Psalm for PHPStan; 6.0.1 moved to spl_object_id(). This is a maintainer who treats PHP minor support as a hard gate and ships majors aggressively.
That means the package is only usable where the runtime matches. For a Laravel shop on a supported PHP version, installation is a non-event. For a shop on an older PHP (which is common — Laravel apps lag PHP upgrades by 12–24 months), the current major is simply unavailable and the team would be pinned to an older branch that is explicitly described as no longer supported.
Technical risk
php is strict. A PHP upgrade or downgrade at the platform level can force a major bump of this package, which in turn can force a bump of PHPUnit, which can break the test suite. This is the realistic failure path, and it is a transitive blast radius, not a direct one.Context object is stateful. Reusing an instance across independent operations without calling reset() produces false-positive cycle detection — the documented [Recursion] marker appearing where no recursion exists. This is the #1 source of confusing bugs for teams that call the library directly, and it is invisible in code review.Key questions for the team
composer why sebastian/recursion-context)require-dev or require?symfony/var-dumper for debug output, and would this overlap or conflict?Context could leak state across jobs?Stack fit
Poor fit as a first-class application dependency; excellent fit as an invisible substrate. Laravel already ships symfony/var-dumper (via Ignition/dd) for developer-facing output, and PHPUnit for tests. recursion-context is the plumbing underneath the latter. The correct posture is: consume, do not adopt. Document it as a known transitive dependency, constrain it in composer.json only if you need to (e.g. "sebastian/recursion-context": "^7.0 || ^8.0" to keep the door open on PHP upgrades), and leave it alone otherwise.
Where a team does want direct use, the sanctioned surfaces should be:
Constraint subclasses, assertModelSynced-style assertions, deep-comparison utilities. Declared with composer require --dev.safeExport()-style helper behind a debug flag, used by a custom error reporter or a Telescope-style panel — never in a request-path hot loop by default.Explicitly out of bounds: domain logic, serialization for persistence, API response shaping, and anything a customer can trigger without a debug flag enabled.
Migration path
There is no migration to run — this is a policy exercise, not an implementation project. The work is:
composer why sebastian/recursion-context and composer show sebastian/recursion-context across the estate to establish the current resolved versions and what pins them.SebastianBergmann\RecursionContext — expect zero to a handful.require (production), move it to require-dev, or replace with symfony/var-dumper if the goal was developer-facing output.Context is held as a shared or singleton service, audit for reset() discipline; in Octane/queue contexts, prefer per-invocation instantiation over shared instances.Compatibility
composer platform config is used to simulate a production PHP version, that config will drive resolution — verify it before assuming you can take a major.Sequencing
Phase 1 (a day): inventory — resolved versions, direct imports, PHP versions per app, Octane/queue exposure. Phase 2 (a day): policy — classify as transitive-only, document the PHP-gate coupling, add a lint or CI step that fails if a production require introduces sebastian/*. Phase 3 (ongoing): fold into the existing PHP-upgrade runbook rather than creating a separate workstream.
Maintenance
Near-zero ongoing cost if it stays transitive — Composer handles it, and the maintainer's release cadence is small and predictable (patch releases for PHP deprecation hygiene, majors for PHP version drops). The real maintenance cost is not in the package; it is in the cascade: a PHP version drop here forces a PHPUnit major, which forces a test-suite validation pass, which occasionally surfaces behavioural differences in comparisons and exporters. Budget that as part of PHP upgrades, not as a separate line item.
The one piece of ongoing hygiene needed is on any direct-usage code: stateful Context objects require discipline around reset(), and there is no static analysis rule that will catch a missed reset.
Support
Support burden is essentially nil at the package level — no vendor to call, no SLA, no paid tier, BSD-3-Clause with no attribution friction. Escalation path for a bug is an upstream GitHub issue on a project with a single primary maintainer, so mean-time-to-fix is best modelled as "whenever the maintainer next ships," not "within our contractual window." Because the package is a PHPUnit dependency, a genuine bug is more likely to be worked around in-house than to block a release; the code surface is small enough (a handful of methods on one class) that a local fork is a realistic last resort, though it should never be a first one.
Scaling
No runtime scaling characteristics to speak of. The implementation deliberately uses spl_object_id() rather than spl_object_hash() precisely to be cheap, and it only tracks objects. Traversal cost is O(number of distinct objects visited), which is the same order as the traversal the caller was going to do anyway — the context adds a hash-set lookup per object, not per scalar.
The one scaling-adjacent concern is memory in long-lived processes: a Context that is created, populated, and never released will hold references to every object it has seen, which is a genuine leak vector in Octane or long-running queue workers. The mitigation is structural — instantiate per operation, never bind as a singleton.
Failure modes
| Mode | Symptom | Blast radius | Mitigation |
|---|---|---|---|
Stale context, no reset() |
Spurious [Recursion] markers; comparisons silently return wrong results |
Test suite produces false passes/failures | Per-invocation instantiation; code review focus on Context lifetime |
| PHP platform upgrade | Composer resolves a new major; PHPUnit major moves; test suite breaks | Build pipeline, all apps on that platform | Fold into PHP upgrade runbook; verify via composer platform |
| PHP downgrade / older platform | Current major unavailable; must pin to an unsupported branch | Cannot take patches | Ensure PHP floor is aligned before adoption; avoid direct use on unsupported runtimes |
| Direct use in production path | Debug/serialization helper invoked in request or job path; adds overhead and potential unbounded traversal on adversarial input | User-facing latency, possible DoS vector | Keep out of require; gate behind debug flags; code-owner review |
| Arrays mistaken for covered | Caller assumes full cycle safety; array-based recursion still infinite-loops | Hung request or worker | Document the object-only limitation explicitly in internal guidance |
| Upstream stalls | No fix available for a defect or new PHP deprecation | Low — small surface, forkable | Accept; monitor as part of PHPUnit upgrades |
Ramp-up
Effectively zero for consumers — no training, no onboarding artifact beyond a paragraph. The only knowledge worth institutionalising is small enough to live in a short internal note: (1) this is a PHPUnit transitive dependency and we treat it as such; (2) it tracks objects only, not arrays; (3) Context is stateful and must be reset or re-instantiated; (4) it belongs in require-dev, never in production require. An engineer can be productive with it in under fifteen minutes, and the realistic onboarding cost is the first time someone debugs a spurious recursion marker.
Bottom line for the product decision: approve implicitly as a transitive dependency, formally decline any proposal to adopt it as a direct production dependency, and treat the PHP-version gate as the only item requiring ongoing governance.
How can I help you explore Laravel packages today?