phpdocumentor/flyfinder
FlyFinder is a utility for Flysystem that finds files by criteria such as hidden status, extension, or path, with wildcard path support. It returns files only, not directories, and integrates easily via Composer.
FlyFinder is not a fuzzy-finder or CLI UI widget — it is a query layer on top of Flysystem: you hand it a League\Flysystem\Filesystem and a specification, and it walks the tree returning files only. Day to day you reach for it when you would otherwise write a recursive listContents() loop with a pile of if statements.
composer require phpdocumentor/flyfinder:^2.0.1
Pin to ^2.0.1: 2.0.0 had a bug where Glob wrote into Flysystem 3 attributes, and 2.0.0 is also the release where the library stopped being a Flysystem plugin (Flysystem 3 removed plugin support) and became a standalone Finder. Verify the PHP floor and the exact Flysystem constraint in the now-public composer.json before committing; 2.x targets Flysystem 3, which means Laravel 9+.
Minimal first use:
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
use Flyfinder\Finder;
use Flyfinder\Path;
use Flyfinder\Specification\HasExtension;
use Flyfinder\Specification\InPath;
use Flyfinder\Specification\IsHidden;
$filesystem = new Filesystem(new LocalFilesystemAdapter(base_path('app')));
$finder = new Finder($filesystem);
$spec = (new InPath(new Path('Models')))
->andSpecification(new HasExtension(['php']))
->andSpecification((new IsHidden())->notSpecification());
foreach ($finder->find($spec) as $file) {
$relativePath = $file->path(); // relative to the Flysystem root
}
Where to look first: the README for the four built-in specifications (IsHidden, HasExtension, InPath, Glob), then the examples/ folder for copy-paste scripts. First real use case: an Artisan command that collects "all non-hidden PHP files under app/" — the exact thing phpDocumentor itself uses the library for.
If you are on Laravel 8 or older (Flysystem 1/2), the API is different: you register new Finder() via $filesystem->addPlugin(...) and call $filesystem->find($spec). Don't mix the two mental models.
Bridging a Laravel disk. Illuminate\Filesystem\FilesystemAdapter exposes the underlying Flysystem instance, so you can query a configured disk instead of hand-building an adapter:
$driver = Storage::disk('local')->getDriver(); // League\Flysystem\Filesystem
$finder = new Finder($driver);
$files = $finder->find($spec);
Keep this in a small service class (SourceFileCollector, AssetScanner) rather than calling it from controllers. The package has ~30 stars and zero dependents; an abstraction layer costs you twenty lines and buys you a fork-and-replace path later.
Build specifications, don't branch in loops. The composed specification is the reusable artifact. Map CLI options onto it once:
$spec = new InPath(new Path($options['path'] ?? '.'));
if ($extensions = $options['ext'] ?? null) {
$spec = $spec->andSpecification(new HasExtension(explode(',', $extensions)));
}
if (! $options['hidden']) {
$spec = $spec->andSpecification((new IsHidden())->notSpecification());
}
Then a single $finder->find($spec) replaces several nested loops and keeps the filtering logic testable in isolation.
Order your andSpecification() chain deliberately. Release 1.0.0 added "detect earlier that a certain direction doesn't have to be accessed" — the traversal prunes based on what it can evaluate on directories. Put InPath first so whole subtrees are skipped before extension checks run.
Use Glob for filename patterns, HasExtension for suffixes. HasExtension(['php', 'phtml']) is a suffix test on the path; Glob is a pattern match (*.blade.php, **/migrations/*.php). Reach for Glob when a suffix test would over-match (e.g. Test.php vs TestData.php) and re-run your smoke test afterwards, since this is the code path that was patched in 2.0.1.
Pipeline pattern in commands. Don't materialize ten thousand paths if you're going to process them: iterate the result, act per file, and report progress. This keeps memory flat and lets a --dry-run flag short-circuit before any writes.
$count = 0;
foreach ($finder->find($spec) as $file) {
$this->line($file->path());
if ($this->option('dry-run')) { continue; }
$this->process($file->path());
$count++;
}
Test against an in-memory adapter. No filesystem fixtures needed — build a League\Flysystem\InMemory\InMemoryFilesystemAdapter, seed a handful of files (including a .git directory and a foo.TXT for the case-sensitivity test), and assert the returned path list. Storage::fake() gives you the same thing inside Laravel tests via Storage::fake('local')->getDriver(). This is the cheapest way to pin down Glob and IsHidden semantics before an upgrade.
Custom specifications. Implement the package's specification interface (isSatisfiedBy()-style predicate over a file entry) when you need size, mtime, or regex rules. Implementing it once and composing it with andSpecification()/orSpecification() is cleaner than post-filtering the result set, because your predicate participates in the traversal pruning.
$filesystem->find(...) will not exist. Instantiate new Finder($filesystem) and call find() on it.League\Flysystem\Filesystem, not FilesystemOperator. Read-only Flysystem instances (e.g. an S3 disk wrapped in a reader) can produce a type error. If you only have a FilesystemReader, you can't hand it straight in — check the signature in the version you install.listContents().new Path('Models') means <disk-root>/Models, not /var/www/app/Models. Passing Storage::path(...) or base_path() into InPath is the most common first-day bug. Path normalizes ././, so new Path('.') is the disk root.new Path('/Models') and new Path('Models') may not behave identically; be consistent and lean on the normalized form. Wildcards are allowed in InPath, which is useful for shallow scoping but easy to over-broaden.IsHidden is name-based. It matches files beginning with a dot and files inside dot-directories, not OS/ACL "hidden" attributes. That's fine on Linux/macOS, and it's why this works the same against S3 and local adapters — but it means Windows hidden-attribute files will not match, and .-prefixed content you do want (e.g. .env.example) needs the notSpecification() removed deliberately.HasExtension(['jpg']) will miss IMG_0001.JPG. If your file population is mixed-case, either pass both casings or implement a custom case-insensitive specification.listContents() per directory. On local disks that's fine; on networked or object-store disks it's a burst of API calls. Narrow with InPath first, avoid running it on every request, and benchmark against a >10k-entry tree (and a remote disk) before putting it on a hot path.Glob attribute-write bug is a data-integrity issue in code that could mutate Flysystem metadata. If you have 2.0.0 anywhere, bump it; if your fork or lock file pins 2.0.0, treat any Glob output as suspect.config/flyfinder.php — there isn't one, and there's no auto-discovery to disable.Path scoping or hidden-file filtering.iterator_to_array(...) — but check whether your installed version also exposes a getFiles() style accessor, because that method name has moved between releases and copy-pasted snippets from older blog posts will not compile.Finder behind your own interface, pin ^2.0.1, and keep a test that asserts a known set of paths from a seeded fake disk so an upstream regression is caught by your CI rather than by a user.How can I help you explore Laravel packages today?