insolita/unused-scanner
Laravel package that scans your project for unused classes and files, helping you spot dead code worth removing. Configure paths and exclusions, run the Artisan scanner, and review a clear report of cleanup candidates.
insolita/unused-scanner answers one question: which packages in composer.json are never referenced in my PHP source? It reads your dependency list, parses your code into tokens/AST, and matches class names it finds against the classes each installed package provides. Anything left over is reported as a candidate for removal.
Install it as a dev tool, not a runtime dependency:
composer require --dev insolita/unused-scanner
Make sure composer install has already run — the scanner needs the package tree in vendor/ to know what each dependency contains.
Create scanner.php in the project root. This is the file it looks for; copy the example config from the README and trim it down. A sensible starting shape for a Laravel app:
return [
'directories' => [__DIR__ . '/app', __DIR__ . '/config', __DIR__ . '/routes'],
'excludeDirectories' => [__DIR__ . '/vendor', __DIR__ . '/storage', __DIR__ . '/tests'],
'composerJson' => __DIR__ . '/composer.json',
'excludePackages' => ['php', 'ext-*', 'lib-*', 'composer-plugin-api', 'composer-runtime-api'],
];
Exact key names vary by version — check vendor/bin/unused-scanner --help and the README example in vendor/insolita/unused-scanner.
Run it:
vendor/bin/unused-scanner
You get a list of packages with no detected class references. Verify by hand before touching composer.json.
First use case: the post-upgrade cleanup. After a Laravel major version bump or a feature removal, run the scanner, cross-check each hit with grep -rn "Vendor\\\\Package" app/ config/ routes/ resources/, then remove the confirmed dead ones. Start with a narrow directories list (app/ only) so you build trust in the output before widening the scope.
Triage into a three-bucket allowlist. Every scan produces three groups: genuinely unused packages, packages used only dynamically, and noise. Encode the middle bucket in excludePackages (or a skipPackages-style key) with a comment explaining why each entry is there — 'spatie/laravel-permission' // middleware resolved by string in routes — otherwise the allowlist rots into "everything is excluded."
Run two configurations. One strict config scoped to app/ with minimal exclusions for signal; one broad config covering config/, routes/, database/, and Blade for completeness. The strict run finds real dead weight; the broad run catches things the strict one flags falsely. Diff the two result sets.
Pair it with icanhazstring/composer-unused. That tool uses Composer's class map and reflection; this one uses source tokens. When both agree a package is unused, that's a strong signal. When they disagree, the disagreement itself is informative — it usually means a string-based reference somewhere.
CI integration, staged. Add the scan to a CI job after composer install:
- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/unused-scanner || true # advisory at first
Run it advisory for a few weeks, tighten the allowlist until output is stable, then drop the || true and treat new unused dependencies as a failing check. Check the tool's actual exit-code behaviour with echo $? before gating — don't assume it returns non-zero on findings.
The removal loop. Never delete on the scanner's word alone:
vendor/bin/unused-scanner # candidate list
composer why vendor/package # who requires it (incl. transitive)
composer remove vendor/package --dry-run # see the cascade
composer remove vendor/package && php artisan test
Some "unused" packages are transitively required by something you do use; composer why tells you that instantly.
Laravel-specific handling. Package discovery, service providers in config/app.php, facade aliases, middleware strings in routes, Blade component tags, and extra.laravel.providers are all string references the parser may not see. Either reference the provider/Facade class in a config file it does scan, or add the package to the allowlist. Prefer the former — it keeps the scanner honest.
Monorepos. Keep one scanner.php per package directory and run the binary from inside each package. Paths in the config should use __DIR__ so it works regardless of the invoking working directory.
'App\\Providers\\FooServiceProvider', app('bar'), ::class built dynamically, container bindings by key, config values naming classes, JSON/YAML/env files — none of these are class references the parser can resolve. This is the single largest source of false positives, and it's why an allowlist is mandatory, not optional.php, ext-*, lib-*, composer-plugin-api, composer-runtime-api will otherwise show up as unused on every run.require-dev is scanned depends on the version/flag — check --help. If you have dev-only tooling that's flagged, scan it with a separate config rather than polluting the production allowlist.nikic/php-parser version conflicts. If composer require --dev fails to resolve, try --with-all-dependencies; if it still fights your lockfile, install it in a throwaway Composer project that points at your app's composer.json rather than in the app itself.storage/ out of directories.__DIR__ in scanner.php; running the binary from a different directory otherwise silently scans nothing and reports everything as unused.0 regardless.composer why, --dry-run, test suite — is what makes removal safe.How can I help you explore Laravel packages today?