barryvdh/composer-cleanup-plugin
Composer plugin that cleans up your vendor directory by removing unnecessary files such as tests, documentation, and examples from installed packages, keeping your deployment lean and reducing disk usage without affecting runtime behavior.
Mental model first. This is a Composer install/update lifecycle plugin, not a Laravel package. There is no service provider, no config/ publish, no container binding, nothing registered at runtime. It hooks the moment Composer unpacks a package into vendor/ and deletes files matching glob rules. Treat it like a line in your Dockerfile or a CI script — it lives in the build phase and has zero presence in the running application.
Step 0 — measure before you install anything. If you can't see the bloat, the plugin will buy you nothing.
du -sh vendor
du -sh vendor/*/* | sort -rh | head -20
find vendor -type f | wc -l
On a modern Laravel app most dependencies already ship .gitattributes with export-ignore, so --prefer-dist installs are already lean. You're looking for a handful of laggard packages holding tests/docs/CI folders, not a systemic problem.
Step 1 — install and pin.
composer require --dev barryvdh/composer-cleanup-plugin
composer show barryvdh/composer-cleanup-plugin # note the resolved version
Then hard-pin it in composer.json ("barryvdh/composer-cleanup-plugin": "0.x.y", no caret). The package is archived with no future releases — a floating constraint gets you nothing but a future surprise.
Step 2 — allowlist the plugin. Composer 2.2+ blocks plugins by default:
{
"config": {
"allow-plugins": {
"barryvdh/composer-cleanup-plugin": true
}
}
}
Commit this. A missing entry doesn't fail the build — Composer warns, skips the plugin, and your install "succeeds" with a fat vendor tree.
Step 3 — rules. Defaults are applied under extra.cleanup.rules keyed by package name, with * as the catch-all:
{
"extra": {
"cleanup": {
"rules": {
"*": ["tests", "Tests", "docs", "examples", "phpunit.xml*", ".github", ".git"],
"vendor/some-legacy-package": ["!docs/examples"]
}
}
}
}
Always check the actual src/ of the plugin (it's small) for the exact key names, glob semantics, and whether negation (!) is supported before you rely on either.
First real use case: a single service that is measurably over a hard artifact limit — a Lambda zip ceiling, a registry bill, a slow cold start. Run the plugin on that one build, diff the result, and stop there. Don't roll it out org-wide.
require vs require-dev before anything elseThis is the trap that wastes an afternoon. The plugin must be installed to run, so:
composer install --no-dev → require-dev packages are skipped → the plugin is never installed and never runs.--no-dev install, the plugin has to go in require, not require-dev.// composer.json — production build uses --no-dev
"require": {
"barryvdh/composer-cleanup-plugin": "0.x.y"
},
"config": {
"allow-plugins": { "barryvdh/composer-cleanup-plugin": true }
}
It's a build tool sitting in your production dependency list, which is ugly but correct. Document why in a comment or the README so the next person doesn't "clean it up" into require-dev and silently disable it.
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev --prefer-dist --optimize-autoloader \
--no-interaction --no-progress
The plugin runs transparently inside that RUN. Do not copy vendor/ from a dev machine or a cache that was built without the plugin — you'll get inconsistent layers.
Start wide, then narrow only the packages that misbehave. The * default should be conservative — tests, docs, examples, CI metadata — and per-package keys should exist solely to remove a pattern for a package where deletion broke something.
"extra": {
"cleanup": {
"rules": {
"*": ["tests", "Tests", "docs", "phpunit.xml*", ".travis.yml", ".github"],
"spatie/laravel-permission": ["!tests/Fixtures"],
"some/package-with-license-in-docs": ["!docs/LICENSE*"]
}
}
}
Treat cleanup as a change to the artifact that must be proven, not assumed:
# baseline (plugin disabled)
composer install --no-plugins --no-dev --prefer-dist
find vendor -type f | sort > /tmp/vendor-before.txt
du -sh vendor
# cleaned
rm -rf vendor && composer install --no-dev --prefer-dist
find vendor -type f | sort > /tmp/vendor-after.txt
du -sh vendor
diff /tmp/vendor-before.txt /tmp/vendor-after.txt | head -100
Gate on this in CI alongside php artisan test and a staging smoke deploy. If the delta is under a few MB, delete the plugin — you didn't have a problem.
Because it's in require, it runs on developers' laptops too. That's usually harmless, but it breaks IDE navigation into package tests, confuses anyone debugging a third-party bug, and wrecks path repositories outright. If it annoys the team, install locally with composer install --no-plugins, or move the rules behind a build-time composer.json variant.
composer install --no-plugins # bypass entirely if a build breaks
rm -rf vendor && composer install --no-plugins # restore deleted files
There is no "undo" — cleanup is destructive and irreversible without a fresh install. Put those two lines in the runbook next to the plugin's pinned version.
allow-plugins. Missing entry = no cleanup, no failure. Assert it in CI: composer config allow-plugins.barryvdh/composer-cleanup-plugin should return true, or grep the install log for the plugin's output.--no-dev + require-dev = no-op. Covered above, but it is the single most common reason "the plugin doesn't work."vendor/ (every Docker build), the plugin is itself part of the install transaction. Packages unpacked before the plugin activates may not get cleaned. Always validate the resulting tree rather than trusting that the plugin ran for everything.*.md, docs/, or LICENSE* rules strip attribution notices. If legal, procurement, or your SBOM process expects license text in the shipped artifact, explicitly exclude LICENSE* and NOTICE* from every rule set — including the * default.path repositories and symlinks. Cleanup writes through symlinks, so it will mutate your actual local package checkout, not just vendor/. If your build matrix has any {"type": "path"} repos, add an exclusion or disable the plugin for that build.--prefer-source installs. The plugin can delete .git and other VCS metadata from a source clone, leaving you with a checkout Composer no longer treats as pristine. Stick to --prefer-dist for builds that use this.--classmap-authoritative. Never let a rule match a path inside an autoloaded namespace. Deleting files under a src/ root breaks dump-autoload --classmap-authoritative and can fatal at runtime on a files autoload entry. Restrict rules to names that are unambiguously dev-only (tests, docs, phpunit.xml*, .github).post-install-cmd / post-autoload-dump scripts run after cleanup. If a package's fixtures or stubs are consumed by your own scripts (or by something like composer-patches), those files may already be gone. Test the full pipeline, not just composer install.composer.json, so editing them invalidates the dependency layer and forces a full reinstall. That's correct behaviour, but budget for it.tests and Tests are different directories on Linux, identical on macOS. List both variants explicitly; never rely on case-insensitive matching in CI.docs deletes vendor/foo/bar/docs, never your project's docs/.vendor/ search get noticeably faster after a real cleanup."config": {
"preferred-install": "dist",
"optimize-autoloader": true
}
plus --no-dev, a multi-stage Docker build, and a .dockerignore. Those usually deliver more than this plugin does, with no install-time code execution.
find vendor -mindepth 2 -maxdepth 3 -type d \
\( -name tests -o -name Tests -o -name docs -o -name examples \) \
-prune -exec rm -rf {} +
A five-line CI step you own, patch, and delete whenever you like.
How can I help you explore Laravel packages today?