ergebnis/composer-normalize
A Composer plugin that normalizes composer.json, sorting and formatting it consistently so contributors don't argue over style. Install it as a dev dependency, allow the plugin, and run it locally or in CI to keep JSON clean and reduce merge conflicts.
Install as a dev dependency in the app or package whose composer.json you want to normalize:
composer require --dev ergebnis/composer-normalize
Allow the plugin (required on Composer 2.2+). Without this the normalize command never registers and you'll get a "command not found" style failure:
composer config allow-plugins.ergebnis/composer-normalize true
Commit the resulting config.allow-plugins entry so every teammate and CI runner gets it.
First use case — clean up a fresh or inherited composer.json before your next PR. Preview first, then apply:
composer normalize --diff # show what would change, don't write
composer normalize # write normalized composer.json
The command will:
composer.json (or the path you pass as the file argument).composer.lock exists and is in sync (unless --no-check-lock).Ergebnis\Json\Normalizer\Vendor\Composer\ComposerJsonNormalizer.--indent-size/--indent-style).content-hash in composer.lock if needed.Where to look first:
examples section of the README — three real-world diffs (pestphp/pest, phpspec/phpspec, phpunit/phpunit) show precisely what the normalizer reorders and collapses, so you know what a big first-run diff will look like.NormalizeCommand / NormalizePlugin in src/ when you need to understand exit codes or lock handling.ergebnis/json-normalizer for the underlying rule engine if you outgrow the defaults.Use three distinct invocations for three distinct jobs:
composer normalize --diff # local review before committing
composer normalize # apply changes
composer normalize --dry-run # CI gate: exit 1 + diff if not normalized
--dry-run is the CI contract: it never writes and exits non-zero when the file is not normalized or the lock is stale.
extra for deterministic outputSniffed indentation is fine for a solo repo, but for a team set it explicitly. Note this lives in composer.json itself, so it travels with the repo:
{
"extra": {
"composer-normalize": {
"indent-size": 4,
"indent-style": "space"
}
}
}
Laravel projects conventionally use 4 spaces; match your editor's .editorconfig so composer normalize and IDE format-on-save don't fight.
{
"scripts": {
"normalize": "composer normalize",
"test:normalize": "composer normalize --dry-run",
"test": [
"@test:normalize",
"@test:types",
"@test:unit"
]
}
}
Then composer test catches unnormalized composer.json in the same run as PHPUnit/Pest/PHPStan. Because the script exits 1, the aggregate script fails correctly.
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- run: composer install --no-interaction --no-progress
- run: composer normalize --dry-run
composer install triggers plugin registration; the dry run then guarantees contributors can't merge a hand-reordered composer.json. If your CI installs with --no-plugins for speed or security, the normalize command simply won't exist — call the phar instead (see below).
composer normalize --dry-run as a pre-commit action.composer normalize --dry-run.composer normalize on staged composer.json only.For hooks that must not modify files mid-commit, always use --dry-run.
When you maintain a package, normalization gives you a stable, reviewable manifest across releases. Practical flow before tagging:
composer validate --strict
composer normalize
composer normalize --dry-run # confirm idempotence
The second dry run should be a no-op — that also proves you didn't leave the lock hash stale.
Pass the path explicitly for each nested manifest:
composer normalize packages/billing/composer.json
composer normalize packages/auth/composer.json
There's no recursive mode; loop in a shell script or Makefile target. Keep each package's extra.composer-normalize block consistent.
Use the distributed phar or Phive when you can't or won't allow plugins:
curl -L -o composer-normalize.phar https://github.com/ergebnis/composer-normalize/releases/latest/download/composer-normalize.phar
chmod +x composer-normalize.phar
./composer-normalize.phar --dry-run
Pin the release tag in CI rather than latest so a normalizer upgrade doesn't turn your pipeline red overnight.
If your CI's lock file can legitimately be out of date (e.g., generated in a prior stage), split the checks:
composer normalize --dry-run --no-check-lock # formatting only
composer install --dry-run # lock validity separately
Conversely, in a deploy script where you don't want the normalizer touching the lock, use --no-update-lock.
extra beats the command line. The README is explicit: "configuration provided in composer extra always overrides the configuration provided via command line options." If indent-size: 2 sits in extra, composer normalize --indent-size 4 is silently ignored. When someone reports "the flag does nothing", check extra first.
Lock hash churn. Because normalization rewrites composer.json, the content-hash in composer.lock changes. The command updates it for you by default, which means every normalize produces a two-file commit. That's correct — but it also means --dry-run fails when the lock is stale, which is often not what you're testing. Use --no-check-lock to decouple.
Do the first normalization as an isolated commit. On an unsorted legacy composer.json, the first run reorders require, require-dev, config, scripts, and extra wholesale. Mixing that diff into a feature PR makes review miserable and guarantees merge conflicts. Land it alone, and add the SHA to .git-blame-ignore-revs.
Sorting is opinionated and can collide with Composer's own sorter. config.sort-packages: true affects how Composer writes require during composer require, while the normalizer applies its own ordering (including platform packages like php and ext-*). The results are consistent but not identical to sort-packages alone; expect a diff the first time both are active and don't try to disable one to "fix" it.
Plugin registration failures are quiet-ish. Two common causes: (1) allow-plugins not set, and (2) the whole composer run used --no-plugins (common in Docker builds and hardened CI). In both cases composer normalize is unavailable. Verify with composer normalize --help; if that errors, it's registration, not formatting.
Sniffed indentation perpetuates mistakes. With no extra config, a file that starts with tabs stays tabs. If your repo is inconsistent, set indent-size/indent-style explicitly before the big normalization commit.
It only reformats; it doesn't validate. composer normalize won't tell you that your version constraints are nonsense. Pair it with composer validate --strict in the same CI step — they catch orthogonal problems.
Malformed JSON fails hard. Trailing commas or comments in composer.json (some editors add them) abort the run. That's a feature, but the error message points at JSON parsing, not at your editor.
--diff and --dry-run differ on purpose. --diff shows changes and writes nothing by default — but it's --dry-run that carries the exit-1-on-mismatch contract. Use --dry-run in gates; use --diff for human review.
Exit codes matter for script chaining. --dry-run returning 1 inside a Composer @script array correctly halts the aggregate script. Don't wrap it in || true in CI "just to keep the build green" — that removes the entire value.
Extension points are limited by design. The normalizer's rules live in ergebnis/json-normalizer; the plugin merely wires ComposerJsonNormalizer into a Composer command. There's no config surface for custom sort orders. If you need domain-specific reordering (say, grouping internal packages first), you'd write your own Composer plugin around Ergebnis\Json\Normalizer, or post-process separately. For non-composer.json JSON files, use json-normalizer directly rather than expecting this plugin to handle them.
Team drift. The single highest-leverage habit: add composer normalize --dry-run to CI and to the pre-commit hook. Without both, developers reformat composer.json in their editor and you get a normalization commit every other week.
How can I help you explore Laravel packages today?