somework / lockrot
Finds abandoned, unmaintained and branch-pinned packages in composer.lock. Composer plugin and standalone PHAR with CI exit codes.
Package info
Type:composer-plugin
pkg:composer/somework/lockrot
Requires
- php: ^7.4 || ^8.0
- composer-plugin-api: ^2.2
Requires (Dev)
- composer/composer: ^2.2
- friendsofphp/php-cs-fixer: ^3.70
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^9.6 || ^10.5 || ^11.5
- symfony/console: ^5.4
- symfony/process: ^5.4
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-17 06:52:55 UTC
README
Finds the packages in composer.lock that quietly stopped being maintained.
Composer warns you about one kind of neglected dependency: the abandoned flag a maintainer sets by
hand. It says nothing about a package whose last release was in 2015, one pinned to a branch
snapshot, or one accepted on PHP 8.4 only because its constraint reads >=5.3.0.
lockrot reports all of them, with the evidence behind each call, the date the data was read, and the
dependency chain that pulled it in. It reads composer.lock and composer.json, and writes neither.
Requires PHP 7.4+ and Composer 2.2+.
Contents
Install · First run · What it reports · In CI · Not every finding is a problem · Configuration · Documentation · Limitations · Roadmap
Install
composer require --dev somework/lockrot
composer config allow-plugins.somework/lockrot true
Composer 2.2+ asks for plugin permission on the first require/install; the second command grants
it. This installs composer lockrot, alias composer rot.
Or run the PHAR, with no dependency added to your project:
curl -fsSL -o lockrot.phar https://lockrot.dev/lockrot.phar php lockrot.phar -d /path/to/project --target-php=8.4
To verify a download, take both files from the release:
curl -fsSL -O https://github.com/somework/lockrot/releases/latest/download/lockrot.phar
curl -fsSL -O https://github.com/somework/lockrot/releases/latest/download/lockrot.phar.sha256
sha256sum -c lockrot.phar.sha256 # shasum -a 256 -c on macOS
Releases from 0.5.0 on are also GPG-signed (lockrot.phar.asc, key 39EC C3F6 4AE8 D06A 9A63 FD99 AB6F 7F52 AE51 3141) and attested by GitHub (gh attestation verify lockrot.phar --repo somework/lockrot); phive install somework/lockrot does the download and the signature check in
one step.
The PHAR, signatures, self-update and the global plugin install →
First run
composer lockrot --target-php=8.4
critical (3)
abandoned sensio/framework-extra-bundle v6.2.10 direct
marked abandoned by its repository, replacement: Symfony; last release 2023-02-24
(3.6 years ago); repository archived on GitHub; …; pulls in 1 flagged package:
doctrine/annotations (abandoned)
silent javibravo/simpleue 2.1.0 direct
last release 2017-11-15 (8.8 years ago); last push 2017-11-18 (8.8 years ago); …
…
high (58)
abandoned doctrine/cache 2.2.0 via doctrine/doctrine-bundle, also via craue/config-bundle,
doctrine/doctrine-migrations-bundle, doctrine/orm and 2 more
marked abandoned by its repository; last release 2022-05-20 (4.3 years ago)
abandoned hoa/ruler 2.17.05.16 via wallabag/rulerz, also via wallabag/rulerz-bundle
marked abandoned by its repository; last release 2017-05-16 (9.3 years ago); …
…
200 packages checked · abandoned 19 · silent 8 · pinned 4 · old-promise 41 · stale 3 · …
priority: critical 3 · high 58 · medium 12 · low 2
pulled in by: wallabag/rulerz-bundle 15 · wallabag/rulerz 14 · wallabag/phpepub 5 · …
…
Abridged — … marks where lines were cut. The full run →
Set
GITHUB_TOKENfor a complete run. Without one, repository-activity checks are capped at 50 packages, and lockrot reports how many were affected. Development dependencies are not checked unless you pass--dev.
What it reports
| Verdict | Meaning |
|---|---|
abandoned |
The package's Composer repository marks it abandoned (Packagist by default), or its repository is archived on GitHub or GitLab |
silent |
No stable release for at least 5 years and no repository push for at least 5 years; an archived repository is reported as abandoned instead |
pinned |
Installed version is a branch snapshot (dev-* or #hash), or the package has no stable release at all |
old-promise |
The installed version was released before the target PHP's GA date, and its require.php constraint is open-ended (>=N, *) for that target |
stale |
Old release or old push, but not old enough (or not on both fronts) for silent |
unknown |
No data could be obtained |
finished |
Matched the built-in or project allowlist — the package is complete by design, not neglected |
ok |
None of the above |
Each finding also carries a priority — critical, high, medium, low, or none for a package
the report does not flag. The verdict sets a base level, which drops one step for a transitive
package and one more for a development-only one, never below low. The priority orders the report
and is carried in every format. --fail-on takes either a verdict or a priority: --fail-on=silent
fails on what was observed, wherever the package sits; --fail-on=high fails on how much it applies
to this project. The baseline stays on the verdict.
A transitive finding names every direct requirement it is reachable from (via a › b, also via c),
not only the one its shortest chain starts from, and each direct requirement's evidence says what
flagged packages it pulls in. The pulled in by: summary line sums that up — transitive
exposure.
Every verdict, signal and priority rule →
In CI
| Code | Meaning |
|---|---|
0 |
No finding reached the fail-on threshold (or fail-on=none) |
1 |
A finding reached or exceeded the fail-on threshold |
2 |
Tool or configuration error |
A network failure is reported as a note and never fails the run on its own, unless you pass
--strict-network. --format=github turns findings into pull-request annotations, --format=sarif
uploads them to the Security tab, --format=gitlab into a Code Quality report and
--format=markdown into a PR comment.
On GitHub Actions, somework/lockrot-action runs the verified release with annotations, a job summary and a metadata cache in one step:
- uses: somework/lockrot-action@v1 with: target-php: '8.4' fail-on: silent
Everywhere else, the PHAR or the Docker image ghcr.io/somework/lockrot does the same job.
Exit codes and every output format →
Not every finding is a problem
A large project rarely starts clean. The baseline records the findings you have already seen and
decided to live with, so CI fails only on what is new or has got worse, without turning --fail-on
off and losing the check entirely. Commit the file: it is a statement about the project, worth
reviewing like any other change, and the only file lockrot ever writes — on this flag alone.
composer rot --target-php=8.4 --generate-baseline
An allowlist covers packages that are finished by design rather than neglected. lockrot ships a
built-in one — psr/*, fig/*, symfony/polyfill-* and more — and those report as finished. To
silence a dependency of your own, add it to extra.lockrot.ignore; package and reason are
mandatory, version and expires optional:
{ "extra": { "lockrot": { "ignore": [
{ "package": "acme/legacy-bridge", "reason": "internal fork, tracked in ACME-123", "expires": "2027-01-01" }
] } } }
Baseline → · Allowlist and ignore →
Configuration
Settings live under extra.lockrot in composer.json. CLI options win over environment variables,
which win over composer.json.
extra.lockrot key |
CLI option | Default | Meaning |
|---|---|---|---|
fail-on |
--fail-on=<verdict or priority> |
none |
Exit 1 threshold: a verdict (stale, old-promise, pinned, silent, abandoned) or a priority (low, medium, high, critical) |
target-php |
--target-php=8.4 |
config.platform.php, else the running PHP |
PHP version used for the old-promise check |
format |
--format=<name> |
table |
table, json, github, sarif, gitlab or markdown |
include-dev |
--dev |
false |
Also check packages-dev, one priority step lower |
install-time |
— | on |
Print a compact block during composer require/update/install |
ignore |
— | [] |
Project allowlist |
| — | --all |
Show every checked package, not only flagged ones | |
| — | --generate-baseline |
Write this run's findings to the baseline file and exit 0 |
extra.lockrot is validated against
resources/lockrot-config.schema.json. Package metadata comes
from the repositories configured in your composer.json, through Composer's own repository layer —
Private Packagist, Satis and mirrors included, with its authentication, proxy settings and metadata
cache. Repository activity comes from GitHub, GitLab and Bitbucket Cloud and is cached for 24 hours.
Full configuration reference → · How lockrot fetches metadata →
Documentation
Everything is at lockrot.dev.
- Verdicts — the eight verdicts, the seven signals, and how priority is derived
- Configuration — every
extra.lockrotkey, environment variable and CLI option - CI — exit codes and all six output formats, with GitHub and GitLab snippets
- Baseline — generating one, the four buckets, and how matching works
- Install-time summary — the block Composer prints, its budgets, and the strict gate
- PHAR — verified and signed downloads, PHIVE,
self-update, and the global plugin install - Internals — the repository layer, two-pass fetching, caching and
--offline - Example run — one full run in three formats, plus a clean one
- Changelog — what changed in each release
Limitations
- Repository activity is checked on GitHub, on GitLab (gitlab.com and every instance in Composer's
gitlab-domains) and on Bitbucket Cloud. GitHub Enterprise and Bitbucket Server are not queried. - Without a GitHub token, only packages that already look stale on release age (no stable release
within
release-warn-years, default 3y, and not alreadyabandoned) are checked against GitHub, capped at 50 per host per run; setGITHUB_TOKENto lift the cap. The same cap applies on Bitbucket until Composer has credentials forbitbucket.org. lockrot reports how many packages this affected. - GitLab is never capped, but its API hides the archived flag from anonymous callers: without
GITLAB_TOKEN(or Composer'sgitlab-token) a GitLab package can besilentbut is neverabandonedfor being archived. Bitbucket Cloud has no archived state at all. - A package is never flagged for what it depends on. A direct requirement that pulls in flagged
packages says so in its evidence (signal S7) and on the
pulled in by:line, but its own verdict, the priority,--fail-onand the exit code read only what was observed about the package itself. - The baseline matches by package name only, and never rewrites itself — entries for packages that have left the lock are reported as stale, not removed.
- The install-time block reads a package's development flag from the lock the transaction is about to
leave behind. Under
composer require --dev … --dry-runno such lock is written, so a package not yet in the lock is treated as production and its priority can read one step high.composer lockroton the real lock always has the flag.
Roadmap
Next up: repository activity from GitHub Enterprise hosts. Reading the lock files bundled inside PHAR tools is being evaluated.
Contributing
Bug reports, fixes and additions to the built-in allowlist are welcome — see
CONTRIBUTING.md. The CLI, configuration keys, output formats, baseline file and
exit codes are the public interface; the PHP classes are not.
Security
To report a security issue, follow SECURITY.md rather than opening a public issue.
License
MIT — see LICENSE. Written by Igor Pinchuk.
