If you maintain a PHP package, there's a small file at the root of your repo that's quietly making life worse for everyone who installs it. That file is composer.lock. Your tests, CI configs, code style configs, and half a dozen other dev tooling files are also hitching a ride into your consumers' vendor/ directories every time they run composer install.
The fix is one file: .gitattributes. It takes about five minutes to set up, and it pays dividends forever.
What .gitattributes Actually Does for Composer Packages
When someone installs your package, Composer doesn't clone your Git repo. It downloads a "dist" zip, which is a tarball that Composer (or Packagist) builds from your tagged release using git archive under the hood. git archive honors a special directive in .gitattributes called export-ignore. Any path marked with it gets stripped out of the archive.
So when you write this:
/tests export-ignore
/composer.lock export-ignore
You're telling Git to leave those paths out when building an archive from the repo. Your repo still has them. Your CI still uses them. Contributors who clone the repo still see and use them. They just don't end up bundled into the artifact your users actually install.
Reducing Bloat in Your Consumers' vendor/ Directory
Open up the vendor/ directory of any moderately-sized Laravel or Symfony project and start poking around. You'll find tests, fixtures, GitHub Actions workflows, PHPStan configs, PHPUnit configs, code style configs, editor configs, Makefiles, contributor guides, and issue templates. None of it is something the consumer cares about. None of it gets used by their application. All of it gets stored on disk and shipped along with their project to wherever it ends up running.
Multiply this across 200+ packages in a typical project and you're talking about meaningful bloat:
- Disk and deployment size. All those unnecessary kilobytes add up across the full dependency tree, and that bloat ships with every deployment.
- Install time. Less to download, less to extract, less to checksum.
- Attack surface for security scanners. Every dev tooling config that references a vulnerable test-only package becomes a flag. Every fixture file that happens to look like vulnerable code becomes a flag. Most of these are false positives, but they still cost engineering hours.
- Cleaner consumer environments. Nobody wants
your-package/.github/dependabot.ymlshowing up in IDE search results when they're trying to debug why your library is misbehaving.
Excluding tests/, CI configs, and dev tooling configs is uncontroversial. There's just no reason for any of it to ship.
The Deep Dive: Why composer.lock Deserves Its Own Conversation
The lock file is where this gets interesting. The conventional wisdom around it has shifted over the years, and there's a specific failure mode worth understanding.
Why You Commit composer.lock for a Package in the First Place
The old advice was "applications commit composer.lock, libraries don't." That guidance is outdated. Composer's current recommendation, backed up by the practical experience of most maintainers, is that you commit it for libraries too. Reproducible installs are valuable for your own development and CI. Contributors get the same dependency versions you tested with, and your CI doesn't go red because some transitive dependency shipped a minor release with subtly different behavior overnight.
Here's the key fact: your composer.lock is irrelevant to consumers. When someone installs your package, Composer reads only your composer.json to learn your version constraints, then merges those constraints into their own resolution graph along with all their other dependencies. The result gets written to their lock file. Your lock file plays zero role in transitive resolution, even when it's sitting right there in vendor/your-package/composer.lock.
So the lock file is useful in your repo and dead weight in your dist zip.
The Scanner False-Positive Problem
This is the failure mode that pushed me to write this post. Vulnerability scanners walk the filesystem looking for known manifest files, and composer.lock is one of them. AWS Inspector does this when scanning workloads. Trivy, Grype, and Snyk do this on container images, source repos, and CI artifacts. Docker image scanning is the most common scenario where this comes up, but the same logic applies to scans of deployed servers, EC2 instances, Lambda functions, and any other artifact that happens to contain a copy of vendor/.
Some of these scanners aren't smart enough to distinguish between the real lock file at the project root, which describes what's actually installed, and the nested lock files inside vendor/some-package/composer.lock, which describe nothing real. They're just files that happened to ship along with the package.
When the scanner parses your nested lock file, it treats every entry as an installed dependency and looks each one up against vulnerability databases. If your lock file pinned an older version of nikic/php-parser for development, the consumer's deployment suddenly looks "vulnerable" to a CVE in a package that isn't even installed. The downstream user gets paged. Their security team opens a ticket. Engineering hours evaporate chasing a false positive that has nothing to do with their actual dependency tree.
The scanner is wrong, technically. But you can't fix every scanner in the world, and your users certainly can't. What you can do is stop shipping the file that triggers the bug.
export-ignore Makes Everyone's Life Better
Adding /composer.lock export-ignore costs you nothing:
- Your repo still has the lock file. Reproducible installs still work for you and your contributors.
- Your CI still uses it. Your test runs are still deterministic.
- The dist zip ships without it. Consumers' scanners have nothing nested to misread.
- Composer's resolution behavior is unchanged. It never used your lock file anyway.
The only reason not to do it is that you didn't know you could.
Important: This Advice Is for Packages, Not Applications
One thing worth being clear about up front: everything in this post applies to libraries and packages distributed via Composer/Packagist.
If you're working on an application like a Laravel app, a Symfony service, or an internal tool, none of this applies. Nobody installs your application as a Composer dependency. There's no "dist zip" being built from your tags. Your composer.lock should absolutely be committed for reproducible deploys, and you don't need to do anything with .gitattributes because there's no archive being shipped to anyone.
This mostly matters when other people/projects install your code. If that's not happening, you can most likely skip the whole exercise.
A Copy-Paste Baseline .gitattributes for PHP Packages
Here's a baseline that covers the common dev tooling in the modern PHP ecosystem. It uses safe wildcards where they consolidate cleanly. Drop it at the root of your package, delete the lines that don't apply to your project, and you're done.
# CI / automation
/.github export-ignore
/.gitlab-ci.yml export-ignore
/.circleci export-ignore
/.travis.yml export-ignore
# AI assistants
/.claude export-ignore
/CLAUDE.md export-ignore
/.cursor export-ignore
/.cursorrules export-ignore
/.copilot export-ignore
/AGENTS.md export-ignore
# Tests
/tests export-ignore
/Tests export-ignore
/phpunit.xml export-ignore
/phpunit.xml.dist export-ignore
# Static analysis
/phpstan*.neon* export-ignore
/psalm.xml export-ignore
/psalm.xml.dist export-ignore
/psalm-baseline.xml export-ignore
# Code style
/.php-cs-fixer* export-ignore
/php-cs-fixer* export-ignore
/.php_cs* export-ignore
/.phpcs.xml export-ignore
/.phpcs.xml.dist export-ignore
/phpcs.xml export-ignore
/phpcs.xml.dist export-ignore
# Refactoring / mutation testing
/rector.php export-ignore
/infection.json export-ignore
/infection.json.dist export-ignore
# Editor / repo metadata
/.editorconfig export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/.idea export-ignore
/.vscode export-ignore
# Note that some of the above (like "/.idea" and "/.vscode ") is most likely already (or should be) in your .gitignore and can be removed
# Build / dev environment
/Makefile export-ignore
/captainhook.json export-ignore
/docker-compose.yml export-ignore
/Dockerfile export-ignore
/.dockerignore export-ignore
# Spell check / docs tooling
/.harper-dictionary.txt export-ignore
# Lock file (the whole point)
/composer.lock export-ignore
GitHub Copilot's project-level instructions live at .github/copilot-instructions.md, which is already covered by the /.github entry above.
Using Wildcards In .gitattributes
The pattern matching in .gitattributes follows the same rules as .gitignore, so you can collapse multiple related entries with globs. The baseline above uses this for PHPStan configs (/phpstan*.neon* covers phpstan.neon, phpstan.neon.dist, and phpstan-baseline.neon in one line) and for PHP-CS-Fixer configs (/.php-cs-fixer* covers all the variant filenames).
One thing to watch out for is overly broad patterns. A pattern like /phpstan* would match a phpstan.neon file, and it would also match a phpstan/ directory if you have one. That matters for packages that legitimately ship PHPStan-related code, like a package providing custom PHPStan rules with a shipped extension.neon consumers actually need. Sticking to patterns that include the file extension (like /phpstan*.neon*) keeps things safe.
The leading / is also worth keeping. It anchors the pattern to the repo root, so a stray nested file with a similar name won't accidentally get excluded.
Verify It Actually Works Before Tagging a Release
Don't trust the file. Trust the output. From your repo root, run:
git archive HEAD | tar -t | sort
This produces the exact file listing that'll end up in your Packagist dist zip. Scan it for anything that shouldn't be there. If composer.lock or tests/ is still showing up, your .gitattributes rule isn't matching. The most common causes are typos, a missing leading slash, or an entry added in an uncommitted change. The .gitattributes rules only take effect for content present at the commit being archived.
A more focused check:
git archive HEAD | tar -t | grep -E 'composer\.lock|tests/|\.github'
Empty output means you're good.
One Last Gotcha: Existing Tags Don't Get the New Behavior
.gitattributes rules only apply to archives built from commits where the rules exist. Your previously tagged versions on Packagist will still ship the lock file, because they were archived from commits that didn't have the export-ignore directive. The fix only takes effect for releases tagged after this change lands.
So:
- Create your new
.gitattributes. - Verify with
git archive HEAD | tar -t. - Cut a new release. A patch bump is fine since this is a packaging-only change.
- Tell consumers to upgrade if they're hitting the scanner false positive specifically.
Wrapping Up
The pattern itself is simple. Keep the lock file for yourself, ship a clean archive to your users. The same trick handles tests, CI configs, and every other dev-only file cluttering up downstream vendor/ directories. Five minutes of work, zero ongoing cost, and your users' security scanners stop paging them at 2am about CVEs in packages that aren't even installed.
Have your composer.lock. Just don't make anyone else eat it.