Find the Composer Packages Your Laravel App Doesn't Actually Use

Find composer unused dependencies in a Laravel app, plus shadow and misplaced ones. Tool comparison, the Laravel false positives, and a CI gate that holds.

Steven Richardson
Steven Richardson
· 10 min read

A client app I inherited had 84 entries in require. Running a dependency analyser on it produced 41 findings in under three seconds. The previous developer had run the same tool, seen the wall of output, decided the tool "doesn't understand Laravel", and uninstalled it.

Most of those 41 findings were false positives. Six were not, and two of those six were a genuine production risk. This is how to tell them apart.

Understand the three problems before running any tool#

Every tool in this space reports on some subset of three distinct problems, and confusing them is why the output looks like noise. Name them precisely before you run anything.

An unused dependency is declared in composer.json but never referenced anywhere in your code. A PDF library from a feature that got cut. Cost: install time, and one more maintainer you are implicitly trusting.

A shadow dependency is the inverse — your code uses it, but you never declared it. You are relying on some other package to pull it in transitively:

// app/Services/InvoiceExporter.php
use GuzzleHttp\Client; // guzzlehttp/guzzle is NOT in your composer.json

// This works today because laravel/framework requires guzzle.
// The day it stops requiring it, this class stops autoloading.

That is a live production risk with no warning shot. Nothing in your test suite predicts it.

A misplaced dependency is in the wrong section — a require-dev package referenced from app/, which breaks the moment someone runs composer install --no-dev on a deploy box. Or the reverse: a production package used only in tests/, which every consumer of your library then has to install for nothing.

Most articles on this topic lead with "clean up unused packages, save disk space". That is the least valuable of the three. Invert it.

Run composer-dependency-analyser for the full picture#

Install shipmonk/composer-dependency-analyser as a dev dependency and run it with no configuration. It is the only one of the three well-known tools that detects all three problem classes in a single pass, and it does it fast enough to run on every push.

composer require --dev shipmonk/composer-dependency-analyser
vendor/bin/composer-dependency-analyser

The package has zero Composer dependencies of its own, so it will not drag a conflicting symfony/console into your lock file — which matters more than it sounds when you are auditing a legacy app.

Here is how the landscape actually breaks down, using the maintainers' own benchmark on a ~15,000-file codebase:

Tool Unused Shadow Misplaced Time
maglnet/composer-require-checker No Yes No 124s
icanhazstring/composer-unused Yes No No 72s
shipmonk/composer-dependency-analyser Yes Yes Yes 2s

composer-require-checker is still worth a run if you maintain a public package — it is stricter about unknown symbols and is distributed as a PHAR rather than a project dependency. composer-unused has a nicer report format for a one-off cleanup. Neither earns a slot in CI once you have the analyser wired up.

Two things break the first run. The tool needs a populated vendor/ directory, so run composer install first or the output is nonsense. And in a monorepo you need to point it at the right manifest with --composer-json packages/api/composer.json, otherwise it scans autoload paths that do not correspond to the dependencies it is checking.

Triage the false positives Laravel always produces#

Laravel resolves an enormous amount of its object graph by string, at runtime, from config. A static scanner sees use statements. This mismatch is the entire reason the first run looks broken, and it is the section nobody writes.

Here is every mechanism I have hit, and what it produces:

Auto-discovered service providers. A package ships its own provider in extra.laravel.providers, Laravel registers it at boot, and your code never names the class:

{
    "name": "spatie/laravel-permission",
    "extra": {
        "laravel": {
            "providers": ["Spatie\\Permission\\PermissionServiceProvider"]
        }
    }
}

Reported as UNUSED_DEPENDENCY. Genuinely used. Ignore it.

Facade aliases resolved from config/app.php. The alias array is a map of strings to class names. Nothing statically references the underlying package.

Class names in config files. config/filesystems.php naming an S3 driver, config/logging.php naming a Monolog handler, config/queue.php naming a connector. All strings.

Artisan commands registered by a provider. The command class exists in vendor/, gets registered at boot, and never appears in your app/.

Blade components and view namespaces. Anything rendered as <x-package::button /> is invisible to a PHP-only scanner. This is the one people miss, because the default file-extension configuration scans .php only — a package used exclusively from a .blade.php file is a false "unused" every time.

Composer plugins and vendor/bin binaries. pestphp/pest, laravel/pint, phpstan/phpstan contribute a binary, not a class you import. If you already run Pint, PHPStan and Rector behind one command, every tool in that chain shows up as unused.

Migration and seeder classes. Referenced by filename, loaded by the migrator.

The correct response to all of these is an ignore entry with a comment explaining which mechanism caused it — not a --ignore-unused-deps flag that silences the entire class of finding. This is the same discipline as a PHPStan baseline you actually intend to burn down: the suppression has to be narrow enough that a new real problem still breaks the build.

Promote shadow dependencies into composer.json first#

Work the shadow list before you delete anything. Promoting a shadow dependency is a composer require with zero code change, it immediately removes a production risk, and doing it first keeps the list readable — removals churn the vendor tree and make the next run harder to compare.

# The analyser reported: nesbot/carbon used in app/Support/BillingPeriod.php
composer require nesbot/carbon

The composer.json diff is one line:

 "require": {
     "php": "^8.4",
     "laravel/framework": "^13.0",
+    "nesbot/carbon": "^3.10",
     "livewire/livewire": "^3.6"
 }

You are not adding a package — it is already in vendor/. You are writing down a dependency you already had, so Composer's solver knows about it and your lock file stops depending on someone else's transitive choices.

Move misplaced dev dependencies to the right section#

Sort the misplaced findings into two piles: things that are genuinely dev-only and have leaked into production paths, and things that were mis-declared from the start.

fakerphp/faker called from database/factories/ is the common false alarm — factories ship in database/ which many teams scan as production code, but Laravel treats factories as dev-time. Add database/factories to the dev paths rather than moving Faker into require.

A real one looks like this: barryvdh/laravel-debugbar referenced from a controller for a "temporary" debug dump that shipped eighteen months ago. That is not a composer.json fix — delete the call.

When the declaration really is wrong, move it:

 "require": {
+    "league/csv": "^9.16"
 },
 "require-dev": {
-    "league/csv": "^9.16",
     "pestphp/pest": "^4.0"
 }

Test it properly by simulating a production install: composer install --no-dev in a clean checkout, then boot the app. That is the only check that matches what your deploy actually does.

Remove a genuinely unused package and prove nothing broke#

Now, and only now, delete things. Run this checklist per package before composer remove:

# 1. Full-text search including strings and config, not just `use` statements
rg -i 'barryvdh|Barryvdh' app config routes database resources tests

# 2. Does it contribute a binary you invoke from CI or a Makefile?
ls vendor/bin

# 3. Does it auto-register a provider?
cat vendor/barryvdh/laravel-debugbar/composer.json | jq '.extra.laravel'

# 4. Remove, then verify autoloading is still coherent
composer remove barryvdh/laravel-debugbar
composer dump-autoload --optimize --strict-psr

# 5. The real test: a clean install from the lock file
rm -rf vendor && composer install --no-dev && php artisan about

Step 5 is not optional. Removing a package that another package still requires transitively appears to work locally, because the files are still sitting in vendor/ from the previous install. Only a clean install from the lock file tells you the truth. --strict-psr requires --optimize and exits non-zero on PSR-4 mapping errors in your own code, which catches the class of breakage where a removed package's autoload rules were quietly covering for a namespace typo of yours.

One thing to be blunt about: a green test suite does not prove a package is unused. It proves your test coverage never reached the code that used it. Deploy to staging and exercise the feature by hand.

Write the ignore configuration so the next run is clean#

Drop a composer-dependency-analyser.php in the project root. It is loaded automatically when present and must return a Configuration object. Group the entries by reason, with comments — the comment is what makes this file survivable in a year when someone asks why Spatie's permission package is on an ignore list.

<?php declare(strict_types=1);

use ShipMonk\ComposerDependencyAnalyser\Config\Configuration;
use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType;

$config = new Configuration();

return $config
    // Classes used only from Blade or config are invisible to a PHP-only scan.
    // Extract them yourself and declare them as used.
    ->addForceUsedSymbols([
        \Barryvdh\DomPDF\Facade\Pdf::class, // referenced only in resources/views/invoice.blade.php
    ])

    // Factories and seeders are dev-time in Laravel, despite living in database/.
    ->addPathToScan(__DIR__ . '/database/factories', true)
    ->addPathToScan(__DIR__ . '/database/seeders', true)

    // Registered via extra.laravel.providers — never referenced in app/.
    ->ignoreErrorsOnPackage('spatie/laravel-permission', [ErrorType::UNUSED_DEPENDENCY])
    ->ignoreErrorsOnPackage('spatie/laravel-activitylog', [ErrorType::UNUSED_DEPENDENCY])

    // Contribute a vendor/bin binary, not an importable class.
    ->ignoreErrorsOnPackage('laravel/pint', [ErrorType::UNUSED_DEPENDENCY])
    ->ignoreErrorsOnPackage('driftingly/rector-laravel', [ErrorType::UNUSED_DEPENDENCY])

    // Resolved by string from config/filesystems.php.
    ->ignoreErrorsOnPackage('league/flysystem-aws-s3-v3', [ErrorType::UNUSED_DEPENDENCY])

    // Optional runtime dep, guarded by extension_loaded().
    ->ignoreErrorsOnExtensionAndPath('ext-intl', __DIR__ . '/app/Support/Money.php', [ErrorType::SHADOW_DEPENDENCY]);

The available error types are UNUSED_DEPENDENCY, SHADOW_DEPENDENCY, DEV_DEPENDENCY_IN_PROD and PROD_DEPENDENCY_ONLY_IN_DEV. Prefer ignoreErrorsOnPackage() and ignoreErrorsOnPackageAndPath() over the global ignoreErrors() — the narrower the entry, the more the check is still worth running.

For classes that only ever appear in YAML or JSON config, addForceUsedSymbols() takes an array of class names you extract yourself. And leave unmatched-ignore reporting on (it is the default) so the file prunes itself as the codebase changes.

Gate the check in GitHub Actions#

A one-off cleanup regrows within two sprints. The value is entirely in the gate, and the gate belongs on pull requests rather than a nightly job — the point is to stop new shadow dependencies arriving, not to re-litigate old ones at 3am.

name: Dependencies

on: pull_request

jobs:
  analyse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: none

      - name: Cache Composer packages
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}

      # A populated vendor/ is mandatory — without it the output is meaningless.
      - run: composer install --no-interaction --prefer-dist

      # Exits non-zero on any finding not covered by the config file.
      - run: vendor/bin/composer-dependency-analyser

Two seconds of runtime means this can sit alongside your existing static analysis rather than needing its own workflow. If you are already caching Composer and npm properly, the caching setup for a Laravel CI pipeline applies unchanged here.

Once the gate is green, pair it with a scheduled security audit of the packages you do keep — knowing which packages you depend on is the prerequisite for knowing which advisories apply to you. And if the audit turned up packages that are used but ancient, automating the upgrade with Rector is the natural next job, ideally as part of a Laravel toolchain that runs as one command.

FAQ#

How do I find unused Composer packages in a Laravel project?

Install shipmonk/composer-dependency-analyser as a dev dependency and run vendor/bin/composer-dependency-analyser from the project root after a composer install. It reads your composer.json, scans every path in the autoload and autoload-dev sections, and reports packages that are declared but never referenced. On a Laravel app the first run will include false positives from auto-discovered providers and config-string class names, so plan to write an ignore configuration before you delete anything.

What is a shadow dependency in PHP?

A shadow dependency is a package your code uses directly but never declares in composer.json — it is present in vendor/ only because one of your real dependencies happens to require it. The usual example is calling Guzzle or Carbon directly because Laravel pulls them in. It works right up until that intermediate package drops the requirement in a minor release, at which point your class stops autoloading in production with nothing in your test suite having warned you.

What is the difference between composer-unused and composer-require-checker?

They solve opposite halves of the problem. icanhazstring/composer-unused finds declared-but-unreferenced packages; maglnet/composer-require-checker finds used-but-undeclared symbols, which is the shadow dependency case. Neither one detects misplaced dependencies between require and require-dev. shipmonk/composer-dependency-analyser covers all three classes in a single pass and is roughly 35–60 times faster than either, which is why it is the one worth putting in CI.

Why does composer-unused report Laravel packages I clearly use?

Because Laravel resolves a large part of its object graph by string at runtime, and a static scanner only sees use statements. Service providers registered through extra.laravel.providers, facade aliases in config/app.php, driver and handler class names in config files, Artisan commands registered by a provider, and Blade components referenced as <x-package::thing /> are all genuinely used without ever being imported. These are real false positives, not tool bugs — the fix is a narrow, commented ignore entry per package rather than a global suppression flag.

Is it safe to remove a package composer-unused flags?

Not without verification. Full-text search the codebase including config files and strings, check whether the package contributes a vendor/bin binary or an extra.laravel.providers entry, then remove it, run composer dump-autoload --optimize --strict-psr, and finally do a clean rm -rf vendor && composer install --no-dev before booting the app. A passing test suite is weak evidence — it only proves your coverage never reached the code path that used the package.

How do I run a dependency check in GitHub Actions?

Add a pull_request-triggered job that checks out the code, sets up PHP with shivammathur/setup-php, caches vendor on a composer.lock hash, runs composer install, and then runs vendor/bin/composer-dependency-analyser. The binary exits non-zero on any finding not covered by your composer-dependency-analyser.php, so it fails the build automatically. Run it on pull requests rather than on a schedule, so new shadow dependencies are blocked at review time instead of being discovered later.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.