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

Php Structure Discoverer Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.


Implementation Patterns

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.


Gotchas and Tips

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 query

Use 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:

  1. Cache in the deploy pipeline, never on first request.
  2. Include the cache in your deploy's clear-then-warm sequence, or version the cache key when you change a definition.

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

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.
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
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai