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

Object Enumerator Laravel Package

sebastian/object-enumerator

Traverses arrays and object graphs to enumerate every referenced object, including nested structures and circular references. A small utility from Sebastian Bergmann's PHP testing stack, commonly pulled in by PHPUnit to walk object graphs safely.

View on GitHub
Deep Wiki
Context7

Getting Started

You probably already have it. sebastian/object-enumerator is a transitive dependency of phpunit/phpunit (pulled through sebastian/comparator), so the first move is to confirm, not install:

composer show sebastian/object-enumerator

Only add an explicit require-dev entry once you have a real call site — declaring it independently creates a second constraint set that can fight PHPUnit's on the next major bump.

Where to look first. The README is one paragraph, and that is the whole documentation surface. Read the class itself instead — vendor/sebastian/object-enumerator/src/Enumerator.php is ~80 lines: one final class, one public method, enumerate(array|object $subject): array. That is the entire API. Skim the release notes for the PHP floor (v8.0.0 dropped PHP 8.3, so v8.x requires PHP 8.4+).

The mental model. Give it an array or an object; get back a flat list<object> of every object reachable through nested arrays and instance properties — including private and protected ones, read via reflection. Cycles are handled by identity (spl_object_id), so each object appears exactly once. Scalars, resources and array keys are ignored; they are not returned.

First use case: a smoke test in tinker. Enumerate a small DTO or value object you already understand, and look at what comes back:

$dto = new App\Data\InvoiceData(
    customer: new App\Data\CustomerData(name: 'Ada'),
    lines: [new App\Data\LineData(sku: 'A-1', qty: 2)],
);

$objects = (new SebastianBergmann\ObjectEnumerator\Enumerator)->enumerate($dto);

array_map(fn ($o) => $o::class, $objects);
// App\Data\InvoiceData, App\Data\CustomerData, App\Data\LineData

Then the first useful thing most Laravel teams do with it — a graph-shape assertion in a feature test, so a refactor that quietly smuggles a repository or a container-resolved service into a DTO fails CI:

it('keeps the invoice payload free of infrastructure', function () {
    $dto = app(InvoiceBuilder::class)->build($order);

    $leaked = array_filter(
        (new Enumerator)->enumerate($dto),
        fn (object $o) => $o instanceof \Illuminate\Database\ConnectionInterface
            || $o instanceof \Illuminate\Contracts\Events\Dispatcher,
    );

    expect($leaked)->toBe([]);
});

That is the shape of nearly every real use: enumerate, filter by type, assert.

Implementation Patterns

Pattern 1 — Wrap it once, don't spray it

The class is final and has a one-method API today, but it is a dependency you did not ask for and might not keep. Wrap it behind an intent-named helper and you get a free exit ramp plus a place to put the docblock that upstream doesn't have.

<?php

namespace App\Support;

use SebastianBergmann\ObjectEnumerator\Enumerator;

final class ObjectGraph
{
    /** @return list<object> */
    public static function of(array|object $root): array
    {
        return (new Enumerator)->enumerate($root);
    }

    /** @return list<object> */
    public static function instancesOf(array|object $root, string $class): array
    {
        return array_values(array_filter(
            self::of($root),
            static fn (object $o): bool => $o instanceof $class,
        ));
    }

    /** @return array<class-string, int> */
    public static function countByClass(array|object $root): array
    {
        $counts = [];
        foreach (self::of($root) as $object) {
            $counts[$object::class] = ($counts[$object::class] ?? 0) + 1;
        }
        arsort($counts);

        return $counts;
    }
}

If it lives only in tests, put it in tests/Support/ObjectGraph.php instead — that keeps the "dev-only, forever" promise visible in the directory structure.

Pattern 2 — A PHPUnit/Pest assertion trait

This is the highest-leverage integration for a Laravel codebase. Static assertions compose cleanly with both PHPUnit and Pest (Pest picks up traits used in tests/Pest.php's uses()).

<?php

namespace Tests\Support;

use PHPUnit\Framework\Assert;

trait AssertsObjectGraph
{
    public static function assertGraphHasNoInstancesOf(string $class, array|object $root, string $message = ''): void
    {
        $found = ObjectGraph::instancesOf($root, $class);

        Assert::assertSame([], $found, $message ?: sprintf(
            'Object graph unexpectedly contains %d instance(s) of %s: %s',
            count($found),
            $class,
            implode(', ', array_map(fn ($o) => $o::class, $found)),
        ));
    }

    public static function assertGraphHasOnlyInstancesOf(array $allowed, array|object $root): void
    {
        $offenders = array_filter(
            ObjectGraph::of($root),
            fn (object $o) => ! array_filter($allowed, fn (string $c) => $o instanceof $c),
        );

        Assert::assertSame([], array_values($offenders), 'Unexpected types in graph.');
    }
}

Use it as a negative assertion ("no service objects in this DTO", "no closures in this queued payload"). Negative assertions survive refactors; positive type-by-type assertions rot fast.

Pattern 3 — Serialization / cache-payload audit

Before you Cache::put(), dispatch() a job, or serialize() an object graph, enumerate it and look for types PHP can't round-trip. This catches the classic "job failed in production because a closure got captured" bug at test time.

$unsafe = array_filter(
    ObjectGraph::of($reportJob),
    fn (object $o) => $o instanceof \Closure
        || $o instanceof \PDO
        || $o instanceof \ReflectionClass
        || $o instanceof \Illuminate\Http\Request,
);

expect($unsafe)->toBe([]);

A complementary belt-and-braces check is simply serialize() inside a try/catch — but the enumerator tells you which object broke it, which the exception often doesn't.

Pattern 4 — Clone / shared-reference audits

If you're mutating a deeply nested graph after a clone and seeing spooky action at a distance, enumerate both graphs and diff by identity. Objects shared between the original and the clone are exactly the aliasing bugs.

$before = array_flip(array_map('spl_object_id', ObjectGraph::of($original)));
$shared = array_filter(ObjectGraph::of($clone), fn ($o) => isset($before[spl_object_id($o)]));

expect($shared)->toBe([]); // a deep clone shares nothing by reference

(spl_object_id values are only unique among live objects — keep the originals referenced while you compare, as above, or you'll get false matches after GC.)

Pattern 5 — Leak triage: before/after a batch

A practical memory-leak hunt for long-running workers and singleton services. Snapshot the graph, run a batch, snapshot again, look at what's newly retained.

$service = app(ReportAggregator::class);

$before = array_flip(array_map('spl_object_id', ObjectGraph::of($service)));

foreach ($orders->take(100) as $order) {
    $service->accumulate($order);
}

$retained = array_values(array_filter(
    ObjectGraph::of($service),
    fn (object $o) => ! isset($before[spl_object_id($o)]),
));

dump(ObjectGraph::countByClass($retained));

Pattern 6 — A local-only Artisan inspector

For internal tooling, an artisan command that reports the class-count histogram of a container-resolved service graph is a genuinely fast way to answer "why is this singleton holding 40k objects?"

protected $signature = 'debug:graph {abstract : Container binding or class name}';

public function handle(): int
{
    if (! $this->laravel->environment('local')) {
        $this->error('Local only.');

        return self::FAILURE;
    }

    $objects = ObjectGraph::of($this->laravel->make($this->argument('abstract')));

    $this->table(
        ['Class', 'Count'],
        collect(ObjectGraph::countByClass($objects))
            ->map(fn ($count, $class) => [$class, $count])
            ->values()
            ->all(),
    );

    $this->line(sprintf('%d object(s) reachable.', count($objects)));

    return self::SUCCESS;
}

Guard it behind local and never register it in routes/console.php for production environments.

Pattern 7 — Pair it with spl_object_id sets for identity work

The enumerator gives you membership, not paths or reference counts. When you need "are these the same object?", build a set of spl_object_id() from the result. Do not use assertContains or == on objects — PHPUnit's loose comparison will recurse the graph again (via sebastian/comparator, which itself uses this package) and is slower and more fragile than an ID set.

Gotchas and Tips

It enumerates objects, not values. enumerate(['a' => 1, 'b' => [2, 3]]) returns []. If you need array keys, values, or structure, this is the wrong tool — use data_get/Arr or VarDumper.

Static properties are not traversed. Only instance properties, via ReflectionObject::getProperties(). A service holding state in a static cache is invisible to this tool. That's a real blind spot for leak triage.

Closures hide their captured state. A Closure is an object whose bound $this and use variables are internal engine state, not reflection-visible properties. Enumerating a graph full of closures will list the closures and stop there. If a leak is hiding inside a closure, this package won't find it.

Internal classes expose almost nothing. PDO, SplFileObject, DateTimeImmutable, WeakReference etc. show up as objects with zero enumerable properties. WeakReference is especially worth knowing: you get the weakref itself, never its target.

Backed enums are objects and will appear in results. If your DTOs use status enums, expect App\Enums\OrderStatus in the output. Filter them out if you're counting "real" collaborators.

Depth is uncapped. There is no max-depth, no predicate, and no result limit. A fully hydrated Eloquent aggregate, a resolved Builder (query builder, grammar, processor, connection), or a container-resolved service graph can produce tens of thousands of objects and cost real time and memory. Scope the input before calling — pass the DTO, not app(). Release 8.1.0 made large graphs "significantly faster," but "faster" is not "free."

Eloquent specifics. A hydrated Collection of models is exactly what you'd expect — one object per model, plus any loaded relations (they live in the protected $relations array, which reflection sees). Unloaded relations are not present and will not be lazily loaded by the enumerator, so results are stable but incomplete: what you enumerate depends on what's already been loaded. Enumerating a single Model instance is comparatively cheap; enumerating a Builder is not.

Uninitialized typed properties are skipped. Don't assert on the existence of a property that hasn't been assigned — it simply isn't there.

Result order is depth-first but not a contract. It's not documented as stable. Sort or key your results yourself; never write an assertion that depends on element order.

Don't extend it — it's final. Wrap it. This is also why Pattern 1 exists: one file to change if upstream behavior or the dependency itself shifts.

Passing a scalar is a type error, not a package exception, on modern versions (array|object union type). Guard your inputs if they come from userland/config.

Version/PHP floor. The sebastian/* majors track PHP versions aggressively — v8.x requires PHP 8.4+. Check composer show against your CI matrix before an upgrade, and let PHPUnit drive the version rather than pinning this package independently.

Never put it on a production request path. Test helpers, local-only artisan commands, one-off triage scripts. dump()/dd()/VarDumper remains the right tool for human-readable debugging; this is programmatic enumeration for assertions and tooling.

Debugging an empty or surprising result. Print array_map(fn ($o) => $o::class, $objects) and compare against your mental model. The two usual causes are (a) everything is a scalar array, or (b) you passed the wrong root — e.g. a service façade object that delegates through static/singleton state the enumerator can't see.

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.
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log