spatie/php-structure-discoverer
Quickly discover classes, interfaces, enums, and traits in your PHP application that match conditions like implementing an interface, with extra metadata per structure and built-in caching to keep things fast in production.
Install and orient yourself. The package is a standalone PHP library first and a Laravel package second — the only Laravel-specific pieces are the config file, the structure-scouts:* artisan commands, and the LaravelDiscoverCacheDriver.
composer require spatie/php-structure-discoverer
# Laravel only
php artisan vendor:publish --tag="structure-discoverer-config"
The two entry points you'll spend 95% of your time in are:
| Class | Purpose |
|---|---|
Spatie\StructureDiscoverer\Discover |
Fluent query builder — the inline, no-setup API |
Spatie\StructureDiscoverer\StructureScout |
Named, cacheable, warmable wrapper around a Discover query |
First use case: build a registry from an interface. This is the canonical "why am I installing this" moment. Say you have an ImportHandler contract and a handful of implementations scattered across app/Imports:
use Spatie\StructureDiscoverer\Discover;
$handlers = Discover::in(app_path('Imports'))
->classes()
->implementing(\App\Contracts\ImportHandler::class)
->get();
// ['App\Imports\CsvImportHandler', 'App\Imports\XmlImportHandler', ...]
Then bind them in a service provider:
$this->app->bind(ImportHandlerRegistry::class, function () {
return new ImportHandlerRegistry(
array_map(fn (string $class) => new $class(), Discover::in(app_path('Imports'))
->classes()
->implementing(ImportHandler::class)
->get())
);
});
Note the return value: strings (FQCNs), not objects. You still instantiate yourself. If you want metadata — parent classes, attributes, flags like isFinal — add ->full() and you get DiscoveredStructure value objects instead. Start with ->get() returning FQCNs; graduate to ->full() only when you need the extra data, since it changes your return type and your caching payload.
Where to look first in the codebase: the README's "Usage" and "Caching" sections are the real documentation; the Discover class surface is small and readable. If you're on Laravel, publish the config immediately so you can tune ignored_files and confirm structure_scout_directories points at app_path().
Sanity-check the discovery boundary before writing logic. Discover::in() takes any directory — it is not scoped to your app. Point it at vendor/ and it will happily scan thousands of files. Always constrain to the narrowest directory that contains what you need (app/Imports, not app/), and test the result count early so you notice when a path is wrong.
Pattern 1 — Interface registry for pluggable behaviour. The most common day-to-day use. Discover, then resolve through the container rather than new, so constructor dependencies work:
$handlers = collect(Discover::in(app_path('Imports'))->classes()->implementing(ImportHandler::class)->get())
->map(fn (string $class) => app($class))
->keyBy(fn (ImportHandler $handler) => $handler::key());
Pair it with Sort::Name if you need deterministic ordering for anything user-facing or test-asserted.
Pattern 2 — Attribute-driven discovery. Laravel-idiomatic and far more explicit than interface-scanning when you want opt-in registration:
#[Attribute(Attribute::TARGET_CLASS)]
class AsDashboardWidget {}
$widgets = Discover::in(app_path('Widgets'))
->withAttribute(AsDashboardWidget::class)
->classes()
->get();
Then in ->full(), read the attribute arguments off DiscoveredClass::$attributes to get metadata (label, priority, permissions) without instantiating anything. This is the pattern that ages best — behaviour is declared on the class, not inferred from an interface that everything accidentally satisfies.
Pattern 3 — Enum discovery for UI options. Cheap and genuinely useful:
Discover::in(app_path('Enums'))->enums()->implementing(HasLabel::class)->get();
Watch the implementing() semantics here (see Gotchas).
Pattern 4 — Structure scouts for anything on a hot path. The inline Discover::in(...)->get() re-scans the filesystem every call. If that discovery feeds a service provider, a route registration, or anything resolved per-request, wrap it in a scout:
namespace App\Discovery;
use Spatie\StructureDiscoverer\Data\Discover;
use Spatie\StructureDiscoverer\StructureScout;
class EnumStructureScout extends StructureScout
{
protected function definition(): Discover
{
return Discover::in(app_path('Enums'))->enums();
}
}
Consume it as EnumStructureScout::create()->get(). First call scans, subsequent calls hit cache. On Laravel you don't implement cacheDriver() — the config's cache.driver (LaravelDiscoverCacheDriver by default) handles it.
Pattern 5 — Warm caches as a deploy step. Non-negotiable if you're using scouts:
php artisan structure-scouts:cache
Put it in your deploy script after composer install and any build step that generates PHP files, and pair it with php artisan structure-scouts:clear on rollback. The whole point of the package in production is that this command runs at deploy time and requests never scan.
Pattern 6 — Combine conditions with any()/exact() for multi-type scans. Conditions are ANDed by default. When you need "classes implementing A or enums implementing B":
Discover::in(app_path())
->any(
ConditionBuilder::create()->exact(ConditionBuilder::create()->classes()->implementing(Arrayable::class)),
ConditionBuilder::create()->exact(ConditionBuilder::create()->enums()->implementing(Stringable::class)),
)
->get();
Reach for any() only when you actually need it — two separate Discover calls are usually more readable and cache better.
Pattern 7 — Packages ship their own scouts. If you maintain a package, you cannot rely on the host app's structure_scout_directories including your path. Register explicitly in your service provider:
public function register(): void
{
StructureScoutManager::add(SettingsStructureScout::class);
}
Otherwise structure-scouts:cache silently skips you and your scout is cold on every request — a bug that only shows up in production profiles.
Pattern 8 — Parallel scanning for big codebases. Only if you measured a problem:
composer require amphp/parallel
Discover::in(app_path())->parallel(100)->get(); // 100 files per process, default 50
Combine with caching rather than substituting for it — parallel speeds up the cold path, caching eliminates it.
implementing() is used for interfaces extending interfaces — extending() will not work. This trips up nearly everyone. The README calls it out explicitly: to find interfaces that extend another interface, use ->interfaces()->implementing(ParentInterface::class). The method name is about the query, not the PHP keyword.
Chains are on by default, and they're expensive. ->extending(Request::class) returns the entire descendant tree, not just direct children:
UserFormRequest extends FormRequest extends Request -> both returned
That transitive resolution is "complicated and resource-heavy." Three escape hatches:
->extendingWithoutChain(X) / ->implementingWithoutChain(X) — direct descendants only->withoutChains() — disable chain resolution globally for the queryUse withoutChains() on any discovery that runs outside a cache. If you find yourself scanning a large app/ directory, chains are usually the reason it's slow.
Closures in ->custom() will fight with caching and parallelism. The condition is part of the discoverer's definition; closure-based conditions don't serialize cleanly into a cache key or across amphp worker processes. If a query is going to be cached or parallelised, implement the condition as a class extending DiscoverCondition:
class AppNamespaceCondition extends DiscoverCondition
{
public function satisfies(DiscoveredStructure $structure): bool
{
return $structure->namespace === 'App';
}
}
Discover::in(app_path())->custom(new AppNamespaceCondition())->get();
Cache invalidation is your job. Adding a new class does not invalidate a structure scout's cache. If a class is missing from a cached result set in production, the answer is almost always "the cache was warmed before that file existed." Two habits that prevent this:
->full() changes your return type. Without it you get string[]; with it you get DiscoveredStructure[] (DiscoveredClass, DiscoveredInterface, DiscoveredEnum, DiscoveredTrait). This is a breaking change for any consumer, and the cached payload shape differs. Decide per-scout and stay consistent — don't have one scout return strings and a sibling return objects if they're consumed together.
extends vs extendsChain on DiscoveredClass. $extends is the direct parent; $extendsChain is the full ancestry. Same for $implements / $implementsChain. Reading the non-chain property when you meant the chain is a silent logic bug — nothing errors, you just get fewer matches.
Discover::in(__DIR__) inside a scout points at the scout's directory, not the project root. Fine if you keep scouts adjacent to what they discover, surprising otherwise. Prefer explicit app_path(...) in Laravel, and be aware app_path() doesn't exist in a pure-PHP context.
ignored_files is a blunt instrument. It's configured as a flat list of paths, not glob patterns. Use it for genuinely toxic directories (generated stubs, fixtures with intentionally invalid PHP). For anything finer, use ->custom() conditions — but see the closure caveat above.
Discovery parses files; it doesn't autoload them. Chain resolution and attribute reading need the structure to be resolvable, so make sure everything under your discovery roots is in composer.json's autoload map. A directory that's scanned but not autoloaded yields partial or missing chain data — and if you then do new $class(), you get a fatal rather than a useful error.
Test with NullDiscoverCacheDriver. Caching silently masks ordering issues, stale definitions, and cold/warm behavioural differences. Run at least one test suite with the null driver so you're exercising the real discovery path, and assert on sorted output (->sortBy(Sort::Name)) so results are stable across OSes — the default sort is filesystem-dependent.
Inline caching exists but can't be warmed. Discover::in(...)->withCache('identifier', new FileDiscoverCacheDriver($dir)) is tempting, but the README is direct: warming these in production is not possible. If a cache needs warming — i.e. anything on a request path — it belongs in a scout. Inline cache is for scripts and one-offs.
Sorting is explicit, not implicit. Default order follows the OS. Anywhere output is compared, displayed, or asserted, pin it:
Discover::in(...)->sortBy(Sort::CaseInsensitiveName)->get();
Sort::Name is byte-comparison; Sort::CaseInsensitiveName is what humans expect in a dropdown.
How can I help you explore Laravel packages today?