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

Laravel Auto Discoverer Laravel Package

spatie/laravel-auto-discoverer

Quickly discover classes, interfaces, enums, and traits in your PHP app that meet certain conditions—such as classes implementing a given interface. Includes built-in caching to keep discovery fast in production.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and publish config

composer require spatie/laravel-auto-discoverer
php artisan vendor:publish --tag="structure-discoverer-config"

The published config/structure-discoverer.php is the first file to read. Three settings matter day-to-day:

  • structure_scout_directories — where structure-scouts:cache looks for StructureScout classes. Defaults to app_path(), which means scouts living in app/ are found for free and anything outside it is not.
  • ignored_files — escape hatch for directories that blow up discovery (storage/, generated code, vendor-ish folders you accidentally pointed at).
  • cache.driver / cache.store — defaults to LaravelDiscoverCacheDriver with the default cache store.

2. Sanity-check the API in tinker before wiring anything

use Spatie\StructureDiscoverer\Discover;

Discover::in(app_path('Models'))->classes()->get();
Discover::in(app_path())->interfaces()->get();
Discover::in(app_path())->classes()->implementing(Arrayable::class)->get();

get() returns a flat array of FQCN strings. If that array looks wrong (empty, or picking up things you didn't expect), the problem is almost always the directory argument or an over-broad condition — not the cache.

3. First real use case: a self-registering registry

The canonical Laravel win is replacing a hand-maintained list in a service provider. Instead of:

protected array $widgets = [SalesWidget::class, UserWidget::class, /* ... */];

you declare intent once:

// app/Discovery/WidgetStructureScout.php
namespace App\Discovery;

use Spatie\StructureDiscoverer\Discover;
use Spatie\StructureDiscoverer\StructureScout;
use Spatie\StructureDiscoverer\Data\DiscoverConditionFactory;

class WidgetStructureScout extends StructureScout
{
    protected function definition(): Discover|DiscoverConditionFactory
    {
        return Discover::in(app_path('Widgets'))
            ->classes()
            ->implementing(Widget::class)
            ->sortBy(Sort::CaseInsensitiveName);
    }
}

and consume it anywhere:

foreach (WidgetStructureScout::create()->get() as $widgetClass) {
    $this->app->bind(Widget::class, $widgetClass); // or register into a registry
}

The first call scans the filesystem; every subsequent call returns the cached array. On deploy, php artisan structure-scouts:cache warms it so no request ever pays the scan cost.

Where to look next: README sections Caching, Chains, and Full information. Those three determine 90% of the behavioural surprises.


Implementation Patterns

Pattern 1 — Ad-hoc discovery for one-off tasks

Not everything deserves a scout. Artisan commands, migrations, and build scripts can call the builder directly:

$models = Discover::in(app_path('Models'))
    ->classes()
    ->extending(Model::class)
    ->get();

No cache, no config, no ceremony. Use this when the result is consumed once (a codegen command, a one-time backfill).

Pattern 2 — Scout as a typed registry (the default choice)

A scout is a named, cacheable, describable query. Treat it as a first-class service:

class PolicyStructureScout extends StructureScout
{
    protected function definition(): Discover|DiscoverConditionFactory
    {
        return Discover::in(app_path('Policies'))
            ->classes()
            ->extending(Policy::class)
            ->sortBy(Sort::CaseInsensitiveName);
    }
}

Then wrap it in a small app-level service so call sites never touch the discoverer API:

class PolicyRegistry
{
    /** @return array<class-string<Policy>> */
    public function all(): array
    {
        return PolicyStructureScout::create()->get();
    }
}

Benefits: one place to change the query, one place to invalidate the cache, and PolicyStructureScout::create()->get() in tests can be swapped for a fake registry.

Pattern 3 — Attribute-driven discovery

This is the most "framework-like" use and pairs well with Laravel's own attribute usage:

#[Attribute(Attribute::TARGET_CLASS)]
class AsPaymentGateway {}

$gateways = Discover::in(app_path('Payments'))
    ->classes()
    ->withAttribute(AsPaymentGateway::class)
    ->get();

The payoff: contributors add a class, slap the attribute on, and it's live — no provider edit, no merge conflict in a growing array. This pattern is what makes the package genuinely worth installing rather than a novelty.

Pattern 4 — Composing conditions with ConditionBuilder

Conditions chain with AND by default. For OR (or nested AND/OR), drop into the builder:

use Spatie\StructureDiscoverer\Data\ConditionBuilder;

Discover::in(app_path())
    ->any(
        ConditionBuilder::create()->classes()->implementing(Arrayable::class),
        ConditionBuilder::create()->enums()->implementing(Stringable::class),
    )
    ->get();

Rule of thumb: if you're reaching for two separate Discover::in() calls and merging arrays by hand, you probably want any().

Pattern 5 — Reusable condition classes

Closures are fine inline; for anything used twice, promote to a class:

class InAppNamespace extends DiscoverCondition
{
    public function satisfies(DiscoveredStructure $structure): bool
    {
        return str_starts_with($structure->namespace, 'App\\');
    }
}

Discover::in(app_path())->custom(new InAppNamespace())->get();

Condition classes are unit-testable in isolation, which closures are not, and they survive refactors of the call site.

Pattern 6 — Deploy-time cache warming

Wire it into the deploy pipeline, in this order:

composer install --no-dev --optimize-autoloader
php artisan structure-scouts:clear
php artisan structure-scouts:cache
php artisan queue:restart

clear then cache is not redundant: clear drops caches for scouts that no longer exist or whose definition changed, cache rebuilds from the current codebase. Running cache on a stale cache key set is how you ship a worker that registers a class you deleted last Tuesday.

Pattern 7 — Parallel scanning for large codebases

If discovery takes more than a second or two on a big app:

composer require amphp/parallel
Discover::in(app_path())->parallel()->get();
Discover::in(app_path())->parallel(100)->get(); // 100 files per process

Only worth it for uncached, cold-path scans (CI, codegen commands, cache warming). Once cached, parallelism is irrelevant.

Pattern 8 — Packages adding their own scouts

Because the consumer app controls structure_scout_directories, a package's scouts in vendor/ are never picked up automatically. Register them in the package's service provider:

public function boot(): void
{
    StructureScoutManager::add(SettingsStructureScout::class);
}

This is the only correct way to ship a scout in a package, and it's the first thing to check when a package's auto-registration works locally but silently no-ops in a consumer project.

Pattern 9 — full() when you need metadata

get() gives strings. If you need isFinal, isAbstract, attributes, or the inheritance chain, use full():

$structures = Discover::in(app_path('Models'))->classes()->full()->get();

foreach ($structures as $structure) {
    /** @var DiscoveredClass $structure */
    $structure->name;          // short name
    $structure->namespace;
    $structure->file;
    $structure->implements;    // direct only
    $structure->implementsChain; // full hierarchy
    $structure->attributes;    // without reflection
}

This replaces a ReflectionClass sweep in most cases and is significantly cheaper than reflecting hundreds of classes.

Pattern 10 — Chain control as a performance lever

Chains are on by default and are the expensive part. If you only care about direct implementors:

Discover::in(app_path())->implementingWithoutChain(Contract::class)->get();
Discover::in(app_path())->extendingWithoutChain(Base::class)->get();

And if you need no hierarchy resolution at all for a given query:

Discover::in(app_path())->withoutChains()->classes()->get();

Measure before optimising, but in a codebase with deep base-class hierarchies, withoutChains() frequently cuts a cold scan in half.

Pattern 11 — Testing with the null driver

Never let tests depend on a warmed production cache. Either bind NullDiscoverCacheDriver for the test environment, or bypass scouts entirely and assert against Discover::in(...) with fixtures in a dedicated tests/Fixtures/Structures directory.


Gotchas and Tips

Interfaces extending interfaces use implementing(), not extending(). This is documented but counter-intuitive and bites everyone once. Discover::in(...)->interfaces()->extending(Foo::class) will return nothing where you expected results; use implementing(Foo::class).

implementing() / withAttribute() / custom() do not require a type filter first. Discover::in(__DIR__)->implementing(Arrayable::class) matches classes, enums, and interfaces. If you expect only classes, say ->classes()->implementing(...). Similarly, a trait or interface showing up in results you thought were classes is usually a missing ->classes().

named() matches the short name, not the FQCN. With multiple User classes across namespaces, you get all of them. Filter with custom() on $structure->namespace for disambiguation.

namespace is the namespace string, not a prefix-checked value. The README example $structure->namespace === 'App' only matches classes literally in the App namespace. Sub-namespaces need str_starts_with($structure->namespace, 'App\\').

Cache keys are derived from the scout class, not its definition. Changing definition() in place does not invalidate existing entries — the stale result persists until you run structure-scouts:clear. Any deploy that adds, removes, renames, or moves classes must clear and re-warm. Consider making structure-scouts:clear && structure-scouts:cache a single deploy step so nobody runs half of it.

withCache() on a raw Discover chain cannot be warmed at deploy time. The artisan commands only know about StructureScout classes. If you inline-cache, the first production request pays the cold cost. Use a scout for anything on a hot path.

Scouts outside structure_scout_directories are invisible to the cache commands — but still work at runtime. This is a maddening failure mode: Scout::create()->get() succeeds (it scans on the fly), so nothing looks broken, but production never gets a warm cache and the first request per worker is slow. Keep scouts under app_path() or register them with StructureScoutManager::add().

parallel() requires amphp/parallel as an extra dependency and is not guaranteed to work in every runtime (containers with restricted process spawning, some shared hosts). Validate on your actual deployment target before relying on it, and always keep the non-parallel path working — it's what you'll fall back to.

custom() closures and the parallel scanner. Closure-based conditions are a common source of "works serially, fails in parallel" reports. If you hit it, promote the closure to a DiscoverCondition class — condition classes are plain serializable objects and behave identically in both modes.

Sorting is not deterministic by default. The default sort is OS/filesystem order. If discovery order affects behaviour (route registration order, middleware priority, first-match-wins resolution), explicitly add ->sortBy(Sort::CaseInsensitiveName) or Sort::Name. This is the difference between a bug that reproduces on your machine and one that only appears on the Linux CI runner.

get() returns a numerically-indexed list, not a map. There's no keying by class name and no deduplication contract you should assume. If you want a registry keyed by name, do it explicitly:

$map = collect(WidgetStructureScout::create()->get())
    ->keyBy(fn (string $class) => class_basename($class));

Discovery is token-based, but consumption still needs autoloading. The discoverer finds names by parsing files; the moment you call new $class or $class::method() on a result, Composer must be able to autoload it. Non-PSR-4, conditionally-declared, or generated classes will appear in results and then fatal at use time. Guard with class_exists() when the directory is anything other than clean app/ code.

Overlapping directories duplicate work. Discover::in(app_path(), app_path('Models')) scans app/Models twice. Pass only the top-level roots.

->full() is heavier than you think. DiscoveredClass objects carry extendsChain and implementsChain arrays; on a large codebase this is a lot of allocation for data you may not read. Call full() at the boundary where you need metadata, not on every registry lookup — and never store the objects (store the FQCN strings and re-discover, or you've just built a cache of a cache).

Run discovery in boot(), not register(), when the results feed container bindings or route registration. register() gives you no guarantee that other providers' bindings exist yet, and the class strings are useless if the classes themselves can't be resolved. For pure container bindings, register() with a deferred provider is fine — but decide deliberately rather than by copy-paste.

Test-environment cache pollution. Laravel's default cache store is often array in tests and database/redis in local dev. Local discovery results can leak into test runs (and vice versa) when the driver is LaravelDiscoverCacheDriver with a shared store. Set cache.store to a distinct store, or bind the null driver, for the testing environment.

Extension point worth knowing: implementing DiscoverCacheDriver (has, get, put, forget) is trivial and is the right answer when you need cache invalidation keyed on a codebase hash or want to piggyback on an existing deploy artifact store. Given the staleness pitfall above, a custom driver that folds a build ID into the cache key is often a better investment than more careful deploy scripting.

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.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core