Packagist
Detection signals
Section titled “Detection signals”Packagist is present when a composer.json or a composer.lock turns up anywhere in the checkout.
Packagist is shipped when the manifest declares a vendor/project name and that name is registered on Packagist. Composer states that “the name property is required for published packages (libraries)”, and Packagist adds that registration happens once, by submitting the repository URL, after which “new versions of your package are automatically fetched from tags you create in your VCS repository”. There is no publish command in a workflow to look for, so the evidence is the registration plus the tags.
Sources: composer.json schema, About Packagist.
Three cases decide most arguments:
- A
composer.jsonwith nonamedeclares no package. It is present and can never be shipped. A manifest that exists only to install PHPUnit or a static analyzer is usually this case. "type": "project"denotes, in Composer’s words, “a project rather than a library”, which is an application shell rather than something other code depends on. It can still be registered, so read the registration rather than the type: the type narrows the odds, the registration settles the answer.- A registered package whose hook has lapsed shows an auto-update warning on its Packagist page and is crawled weekly instead of on push. It is still shipped, on a slower path, so report the stale registration rather than the absence of one.
Release track
Section titled “Release track”Packagist takes the tag-published track. Nothing is uploaded and no publishing credential exists, because the registry reads the forge: a package is registered once and thereafter updated by a forge integration or a weekly crawl, and the tag is what carries the version. That is the evidence that assigns the track, and it is why the release area’s preamble drops the four rules that describe an upload. The roster records "track": "tag-published" for Packagist and the preamble names it there.
Verified 2026-07-31 against https://getcomposer.org/doc/04-schema.md and https://packagist.org/about.
Version badge
Section titled “Version badge”Shields.io serves this one at img.shields.io/packagist/v/VENDOR/PACKAGE, taking the two halves of the package name as two path parameters. ?include_prereleases widens it past stable releases.
The default label is packagist. Keep it.
Link the badge to the package page:
[](https://packagist.org/packages/VENDOR/PACKAGE)The vendor/project name is fixed when the repository is first submitted and Packagist does not let it change afterwards, so the badge path, the link, and the require line below all carry the same string. Read it from composer.json’s name field, and if any of the three disagrees, that one is the typo.
Install command
Section titled “Install command”composer require vendor/packageComposer documents require as adding a package to composer.json in the current directory, and as selecting a version constraint itself when the command names none. Let it. A README that hardcodes vendor/package:^3.1 is a constraint that goes stale on the next minor release, and the reader who pastes it after version 4 ships gets version 3.
A package that installs as a project rather than a dependency, such as a skeleton or a standalone application, shows composer create-project vendor/package instead. That is a different command with a different result, so show whichever one the project is, not both.
Verified 2026-07-31 against Composer CLI, About Packagist, and services/packagist/packagist-version.service.js in badges/shields.
License declaration (R-COM-01)
Section titled “License declaration (R-COM-01)”composer.json declares it in the top-level license property, which is either a string or an array of strings. Composer’s schema recommends SPDX identifiers and points at the SPDX registry for the full set, and it gives "proprietary" as the identifier for closed-source software.
The two multi-license forms mean different things and the schema distinguishes them. A disjunction, a choice the consumer makes, is written either as an array of identifiers or as one string with the alternatives separated by or inside parentheses. A conjunction, where every named license applies at once, is written as one string with the identifiers separated by and inside parentheses. An array and an and string are therefore not interchangeable, and reading one as the other changes what the package is offering.
The property is optional, described as highly recommended rather than required, so a composer.json with no license key is a manifest declaring nothing rather than an invalid one. That leaves the root license file as the only statement, which is worth raising: a package published to Packagist with no declared license shows nothing to a consumer reading the registry.
Source: The composer.json schema.
Funding platform name
Section titled “Funding platform name”Tidelift’s platform name for this ecosystem is packagist, so the GitHub funding file’s entry reads tidelift: packagist/<package-name>. The accepted key format is PLATFORM-NAME/PACKAGE-NAME, described in github.md beside the rest of the accepted keys.
One wrinkle belongs to this ecosystem alone. A Packagist package name is itself vendor/project, so the value carries a second slash where the other five platforms carry one. Confirm the exact string against the project’s own Tidelift subscription before shipping the line rather than assembling it from the two halves.
The roster at skills/oss-audit/ecosystems.json records "tidelift": "packagist" and is the canonical copy. This line exists because a single-skill install of oss-community does not carry that file; where the two disagree, the roster is right and this line is corrected to it.
Verified 2026-07-31 against The composer.json schema and Displaying a sponsor button in your repository.
Toolchain and matrix (R-CI-03)
Section titled “Toolchain and matrix (R-CI-03)”Neither the PHP project nor Composer publishes a GitHub Actions setup action, so there is no first-party equivalent of actions/setup-node here and no upstream-documented matrix input. What upstream does publish is the PHP official image and a fixed PHP on each runner image, and both give a way to build the matrix.
Source: docker-library/php, actions/runner-images, Ubuntu 24.04.
The strongest documented option on either forge is to run the job in the PHP official image, which GitHub Actions supports as a job-level container: and GitLab as image:, and to vary the tag across the matrix. The support claim itself lives in the php entry of require in composer.json, which takes a constraint such as >=7.4, so the matrix covers each PHP release line that constraint admits.
strategy: matrix: php: ['8.2', '8.3', '8.4']container: php:${{ matrix.php }}-cliThe runner image’s own PHP, 8.3.6 with Composer on ubuntu-24.04, is a single version that changes when the image changes, so it serves a single-version pipeline and not a matrix. Report the missing action as what it is, an ecosystem with no first-party setup action rather than an ecosystem that cannot be matrixed. A first-party action from the PHP project or from Composer would retire the gap and let the matrix live in with: alongside every other ecosystem here.
Sources: Composer schema, require, docker-library/php.
Dependency caching (R-CI-04)
Section titled “Dependency caching (R-CI-04)”Composer’s cache holds the zip archives of packages. cache-files-dir defaults to $cache-dir/files, cache-dir defaults per platform, and COMPOSER_CACHE_DIR overrides the whole thing. composer config cache-files-dir prints the resolved path, which is what to read rather than assuming the default.
Key on composer.lock. Do not cache vendor/, which holds installed dependencies that composer install recreates from the lockfile, and which carries autoloader state generated for the PHP version that installed it.
On both forges this is an explicit cache, since no setup action exists to carry one. On GitLab set COMPOSER_CACHE_DIR to a project-relative directory and key cache:key:files on composer.lock, because GitLab caches only paths inside the project directory.
Sources: Composer config reference, Composer CLI, environment variables, GitLab CI/CD YAML reference, cache.
Test command (R-CI-06)
Section titled “Test command (R-CI-06)”Composer defines no canonical test script name. It runs whatever the project declares under scripts in composer.json through composer run-script <name>, so a project that declares test there is called as composer run-script test and CI gets the same entry point a contributor has.
Where no script is declared, PHPUnit documents its own project-local invocation, ./vendor/bin/phpunit, after composer require --dev phpunit/phpunit. Prefer the declared script when one exists, so the arguments stay in composer.json rather than in the pipeline. A repository with phpunit.xml and no scripts.test entry has a suite and no declared command, which is worth raising as the smaller half of the same question.
Sources: Composer CLI, run-script, PHPUnit, installation.
Verified 2026-07-31 against https://getcomposer.org/doc/03-cli.md, https://getcomposer.org/doc/04-schema.md, https://getcomposer.org/doc/06-config.md, https://docs.phpunit.de/en/12.0/installation.html, https://github.com/docker-library/php, https://github.com/actions/runner-images, and https://docs.gitlab.com/ci/yaml/.
Automated dependency updates (R-SEC-03)
Section titled “Automated dependency updates (R-SEC-03)”Dependabot’s composer value carries version updates and security updates, for Composer v2, including private repositories and private registries. Vendored dependencies are not supported.
On GitLab the Renovate manager is composer.
Lockfile and frozen install (R-SEC-08)
Section titled “Lockfile and frozen install (R-SEC-08)”Composer writes composer.lock without being asked, and composer install “will use the exact versions from there instead of resolving them”, so the install command is already the frozen one and there is no flag to add.
What Composer does not do is fail when that lockfile has drifted from composer.json, and its documentation describes no install option that makes it fail. The gate is a separate step: composer validate --strict, whose --check-lock option checks that the lockfile is up to date and whose --no-check-lock option is documented as the way to “Do not emit an error if composer.lock exists and is not up to date”. A pipeline running composer install alone installs a stale resolution happily, so read for the validate step before reporting this ecosystem satisfied.
What breaks the first time you commit the lockfile
Section titled “What breaks the first time you commit the lockfile”Resolution reads the PHP you run, and Composer publishes the fix. A lock generated on the newest PHP can name a package release the oldest matrix job cannot install. The config.platform setting exists for this, letting a project “fake platform packages (PHP and extensions) so that you can emulate a production env or define your target platform”. Set config.platform.php to the lowest PHP the matrix tests before generating the lock, and Composer then makes sure “no package requiring more than” that version “can be installed regardless of the actual PHP version you run locally”.
That setting makes the check inexact, so add the one Composer names. Its own documentation warns that with a faked platform “the dependencies are not checked correctly anymore”, and directs a project to run composer check-platform-reqs during deployment. Run it in each matrix job, so a real extension or version gap surfaces on the job that has it rather than on the machine that wrote the lock. platform-check is the related autoloader control, defaulting to php-only and taking true to also check that extensions are present.
Platform in the operating-system sense is not a dimension. Composer’s platform packages are the PHP runtime and its extensions, and the lockfile records no per-architecture entry, so a lock written on macOS installs on a Linux runner unchanged.
Verified 2026-07-31 against the Composer config reference.
Static analysis (R-SEC-09)
Section titled “Static analysis (R-SEC-09)”CodeQL does not support PHP; it appears nowhere in the supported languages list, so CodeQL default setup is not the answer here and adding an empty CodeQL configuration would be worse than none.
GitLab ships one: its SAST table lists PHP under the Semgrep-based analyzer with GitLab-managed rules, in the tier that carries the open source analyzers.
On GitHub the analyzer comes from the PHP ecosystem itself. Psalm’s security analysis, enabled with the --taint-analysis command line flag, traces connections between user-controlled input and places unescaped input should not reach, across taint types including SQL injection, cross-site scripting, shell command injection, insecure deserialization, remote code inclusion, and server-side request forgery. Run it on pull requests and make its result a required check, which is what the rule asks of a project whose language CodeQL does not cover.
Vulnerability watch (R-SEC-11)
Section titled “Vulnerability watch (R-SEC-11)”The dependency graph parses composer.lock as its recommended file, with composer.json as an additional file. The Composer row marks static transitive dependencies, Dependabot graph jobs, and automatic dependency submission all unsupported, so what the graph knows is what the committed lockfile says.
Advisories come from the GitHub Advisory Database, which names this ecosystem Composer against the packagist.org registry.
osv-scanner reads composer.lock.
Verified 2026-07-31 against Dependabot supported ecosystems and repositories, Dependency graph supported package ecosystems, GitHub Advisory Database, Composer command line interface, Psalm security analysis, CodeQL supported languages and frameworks, GitLab SAST, Renovate managers, and osv-scanner supported languages and lockfiles.
Concrete flow for the decisions SKILL.md makes, for a PHP package listed on packagist.org. Packagist runs on the tag-published track: a package is registered once by submitting its repository URL, and every version after that comes from a tag Packagist reads out of the repository. Packagist’s own words are that new versions “are automatically fetched from tags you create in your VCS repository”. Nothing is uploaded and no publish command exists, so STANDARD.md’s release preamble places R-PUB-01 through R-PUB-04 outside this track as a whole. R-PUB-07 is the rule that does apply, and it asks where the credential behind the update link lives. R-SEC-13’s restriction on who may create a tag stands where the approval gate would stand, because creating the tag is the publish.
Source: Packagist, About.
Contents
Section titled “Contents”- Gather facts (Step 1)
- Configure trusted publishing (Step 2)
- Write the hardened release workflow (Step 3)
- Gate on manual approval (Step 4): the gate is on tag creation
- Verify provenance (Step 5): a gap, not a check
- Describe and sign what the release attaches (Step 6)
- Registering a package for the first time
Gather facts (Step 1)
Section titled “Gather facts (Step 1)”Read composer.json. Its name field is the vendor/project identity, and Packagist treats that name as immutable once the package is registered, so a rename is a new package rather than an edit. Get the owner and repository from the support.source or homepage entry, falling back to git remote get-url origin.
A library’s composer.json should carry no version field: the tag is the version, and a hardcoded one goes stale the moment a tag disagrees with it. If the repository has one, say so, because the version check every other ecosystem runs in CI has nothing to compare against here otherwise.
Check whether the package is registered by opening its page at https://packagist.org/packages/<vendor>/<project>. Look at that page for the auto-update warning Packagist shows when no hook is set: that warning is the observable evidence R-PUB-07’s Check: names, and it is the difference between a package updated on every push and one crawled weekly.
Configure trusted publishing (Step 2)
Section titled “Configure trusted publishing (Step 2)”There is no OIDC exchange and no publish token here, so what this step configures is the link that tells Packagist a new tag exists. R-PUB-07 asks that the link be a forge-side integration the registry itself configures, with no registry API token in CI secrets or in any workflow file. The two forges answer that very differently.
GitHub
Section titled “GitHub”Take the tokenless path, which is the one Packagist recommends. Log in to Packagist via GitHub, grant the Packagist application access to the organizations you publish from, and Packagist installs the hook itself. Nothing is stored on the forge and nothing is stored in CI, so there is no credential to leak, rotate, or scope. A package that has gone stale is fixed by triggering a manual account sync from the Packagist profile rather than by touching the repository.
The manual fallback, for a repository the application cannot cover, is a repository webhook with payload URL https://packagist.org/api/github?username=<packagist username>, the Packagist API token as the webhook secret, and push events only. That puts a real token in a forge setting. It is still not a CI variable, so no workflow can read it, which is the distinction R-PUB-07 keys on; prefer the application path anyway, because it stores nothing at all.
GitLab
Section titled “GitLab”There is no tokenless option on GitLab. Configure the integration at the project’s Settings, Integrations, Packagist, which requires the Packagist username and API token. GitLab’s own integration model declares both as required, and it types the token as a password, so it is write-only in the interface once saved.
Keep the token there and nowhere else. A token in a project integration setting is not readable by a CI job; the same token in a CI/CD variable is readable by every job in the project, which is the exact failure R-PUB-07 exists to catch. Do not add a curl step to a pipeline that calls the update endpoint with a masked variable and call it equivalent.
Note that GitLab’s rendered documentation page for this integration no longer exists, so the requirement above is confirmed against the integration model in GitLab’s source rather than against a docs page. Re-read it before writing this section, and report the GitLab half as met by placement rather than by absence of a credential, because a credential does exist here.
Write the hardened release workflow (Step 3)
Section titled “Write the hardened release workflow (Step 3)”There is no publish job to harden, because no job publishes anything. The workflow’s job is to make the tag safe to create, since the tag is what ships.
This is the one departure from the workflow shape SKILL.md states, and it is the tag-published track’s, not this ecosystem’s. The jobs below stay in .github/workflows/ci.yml under name: CI, with no Release workflow and no release environment, because pushing the tag is the publish and there is nothing left for a separate workflow to do. test keeps its name, and the asset job is still github-release.
name: CIon: [push, pull_request]jobs: test: runs-on: ubuntu-latest container: php:<the version composer.json requires>-cli # pin by digest once chosen steps: - uses: actions/checkout@v7 with: persist-credentials: false - run: composer validate --strict - run: composer install --no-interaction --no-progress - run: composer run test # oss-ci decides the actual command from CONTRIBUTING.md (R-CI-02)GitHub-hosted runners ship a PHP release, and most PHP projects reach for a third-party setup action instead so they can choose the version. This reference names none, because it would be naming a third-party action that runs before the project’s own code; a container image the project pins by digest gets the same version control with nothing extra to vet. oss-ci decides which of the two the project uses. composer validate --strict is what catches a malformed or stale composer.json before a tag freezes it.
The release is a tag and a push. Nothing else runs:
git tag v1.2.3git push origin v1.2.3Packagist notices it through the hook configured in Step 2, on that push. Without a hook, the package is crawled weekly instead, and a release therefore appears on a schedule nobody chose. The documented manual trigger is a POST to https://packagist.org/api/update-package?username=<username>&apiToken=<token>; treat it as a one-off repair run by a person, not as a pipeline step, because putting it in a pipeline is what puts the token in CI.
Never move or delete a released tag. Composer resolves a version to a tag, so repointing one changes what every later composer install fetches while the version number stays put. R-SEC-13 is the rule and oss-harden configures it.
Gate on manual approval (Step 4): the gate is on tag creation
Section titled “Gate on manual approval (Step 4): the gate is on tag creation”R-PUB-04 is not applicable on the tag-published track. There is no run between building and public availability to approve, because there is no build and no upload: the tag becomes installable as soon as Packagist reads it.
R-SEC-13’s tag-creation clause is the control that remains. Restrict refs/tags/* on GitHub with a repository ruleset that blocks tag update and deletion and restricts creation to named principals, or on GitLab with protected tags covering the release tag pattern. oss-harden writes both. Report R-PUB-04 as not applicable with R-SEC-13 named as what stands in its place, rather than reporting a gate that does not exist.
Verify provenance (Step 5): a gap, not a check
Section titled “Verify provenance (Step 5): a gap, not a check”Packagist serves no build provenance and no signature. Its own description of itself covers registration, hooks, and crawling; it names no attestation, no signing, and nothing a consumer can verify a downloaded package against beyond what the forge already served. There is also no build to attest: what a consumer installs is the repository contents at the tag, assembled by Composer.
Source: Packagist, About.
The strongest available substitute is a signed tag. The tag is the release, so signing it is the only thing that binds a version to an identity, and a consumer can check it directly against the repository. That is R-SEC-05 and oss-harden owns it. A forge attestation has nothing to cover here, because no artifact is produced.
This sits below R-PUB-03, which asks for provenance tied to the exact published artifact and the workflow that built it. Report it as unmet with the registry limitation named. It would retire the day Packagist served an attestation or a signature for a version, and not before, since a signed tag proves who tagged the commit and not what any intermediary served.
Describe and sign what the release attaches (Step 6)
Section titled “Describe and sign what the release attaches (Step 6)”Only for a release that attaches a built asset to the forge release. A library installed from Packagist attaches nothing, and the source archives the forge generates for a tag are not built assets, so most packages go straight to Step 7. The case this section covers is a project that also ships a PHP archive or a compiled binary on the forge release.
This reference names no SBOM generator for PHP. The ones in common use are third-party tools rather than part of Composer, and a tool that reads the dependency tree inside a release workflow is one the maintainer vets before it goes there. composer.lock is the closest thing the project already has, and it is neither SPDX nor CycloneDX, so publishing it does not satisfy R-PUB-05’s format requirement; say that rather than presenting the lockfile as a bill of materials.
Two rules apply here and they ask for different things. R-PUB-05 wants that inventory. R-PUB-06 wants the assets signed, or listed by hash in a signed manifest. A manifest of hashes answers the second and nothing about the first, so do not report R-PUB-05 as met by publishing one.
What answers R-PUB-05 without a generator, on GitHub, is the forge’s own export of the repository’s dependency graph, which is already SPDX and needs nothing installed. The gh api step below writes it into dist/, so it ships as a release asset for R-PUB-05 and is listed in SHA256SUMS and attested alongside the assets for R-PUB-06:
github-release: runs-on: ubuntu-latest permissions: contents: write id-token: write attestations: write artifact-metadata: write steps: - uses: actions/download-artifact@v8 with: name: build path: dist/ - run: gh api repos/${{ github.repository }}/dependency-graph/sbom --jq .sbom > dist/sbom.spdx.json env: GH_TOKEN: ${{ github.token }} - run: (cd dist && sha256sum *) > SHA256SUMS - uses: actions/attest@v4 with: subject-checksums: SHA256SUMS - run: gh release upload "$GITHUB_REF_NAME" dist/* SHA256SUMS env: GH_TOKEN: ${{ github.token }}State both of the export’s limits to the maintainer rather than leaving them to be discovered. It is GitHub only, so a GitLab project keeps this gap and R-PUB-05 stays unmet there with the reason named. And it covers the repository’s declared dependency graph rather than what is inside the asset, which is exact for a PHP package, because it bundles no dependencies and a consumer resolves them from those same declarations. The graph resolves past the direct dependencies only where composer.lock is committed, which R-SEC-08 already requires. Read the output back once with gh api repos/<owner>/<repo>/dependency-graph/sbom --jq '.sbom.packages | length' before reporting the rule met: a graph the forge does not parse for this ecosystem returns a near-empty package list rather than an error.
sha256sum runs from inside dist/ so the names it writes are the names the assets carry on the release. subject-checksums makes every file in the manifest a subject of the attestation in its own right, by name and digest; attesting SHA256SUMS itself with subject-path would leave a consumer able to verify the manifest and nothing about the assets it lists.
A consumer verifies an asset, then checks the rest of the download against the manifest:
gh attestation verify <asset> --repo <owner>/<repo>sha256sum -c SHA256SUMSRun the first command against each asset downloaded, never against SHA256SUMS, which is a subject of nothing. On GitLab CI/CD the forge attestation above is unavailable, so a GitLab release can carry the same SHA256SUMS with nothing signing it; say that rather than presenting the file as provenance.
The four grants above are copied exactly, on this job only, and the workflow’s top-level block stays contents: read. Narrowing anything else, pinning each uses: to a commit SHA, and auditing the result are oss-harden’s.
Registering a package for the first time
Section titled “Registering a package for the first time”Submit the repository URL at https://packagist.org/packages/submit, logged in. Packagist reads composer.json from the default branch for the name and reads the existing tags for versions, so a repository that already has release tags arrives with its history rather than starting at the next one. Confirm the vendor/project name is the one intended before submitting, because it cannot be changed afterwards. Configure the hook from Step 2 immediately, since a package registered without one is crawled weekly and looks broken to the first person who tags a release and waits.
Step 7 is in SKILL.md: read each R-PUB rule’s Check: line against what this file produced, and fix what fails before reporting done.
Verified 2026-07-31 against Packagist, About and GitLab’s Packagist integration model at gitlab-org/gitlab, app/models/integrations/packagist.rb, which is where the username and token requirement is readable now that GitLab’s rendered page for the integration is gone.
Version sources (R-CHG-03)
Section titled “Version sources (R-CHG-03)”The tag is the version, and composer.json should not carry one. Packagist states that “New versions of your package are automatically fetched from tags you create in your VCS repository”, and Composer’s schema says of the version field that “In most cases this is not required and should be omitted”, that it is optional where the repository can infer the version from the tag name, and that stating it yourself “will most likely end up creating problems at some point due to human error”.
So a well-formed PHP library has two things to compare, the tag and the changelog. A repository that does state version in composer.json has a third, and the fix is to delete the field rather than to keep it in step. composer.lock belongs to an application and records resolved dependencies, so it is not a version source for a published library.
Version syntax (R-CHG-02)
Section titled “Version syntax (R-CHG-02)”Composer accepts X.Y.Z or vX.Y.Z and strips the v prefix from the tag name to get the version number, so v1.1 and 1.1 are one version and either tag form works. Stability comes from the suffix, across five levels from least to most stable: dev, alpha, beta, RC, and stable. The schema’s own examples include 1.0.0-alpha3 and v2.0.4-p1, and the uppercase RC and the -p1 patch suffix are both idiomatic here and outside what SemVer spells that way.
Branches are versions too, which is the deviation most likely to confuse a reader. Composer adds a -dev suffix to numeric branches and prefixes every other branch imported from a VCS with dev-, so a project has resolvable versions it never tagged. Those are not releases and get no changelog entry.
Major version in package identity (R-CHG-07)
Section titled “Major version in package identity (R-CHG-07)”Packagist does not encode the major version in package identity. The vendor/project name is fixed at submission and is stable across majors, so R-CHG-07 does not reach this ecosystem. A PHP project that versions its namespace instead is changing source, which R-CHG-02 already governs as an incompatible change to the public API.
Withdrawing a release (R-CHG-01)
Section titled “Withdrawing a release (R-CHG-01)”Packagist documents no withdrawal. Its about page covers submitting a package, how versions are fetched from tags, how the crawl is scheduled, and how to force an update, and says nothing about deleting a package or removing a published version. Composer’s schema documents no per-version state either.
Source: Packagist about and Composer schema.
The strongest documented fallback has two parts, and neither is a yank. Because the version comes from the tag, deleting the tag in the repository removes that version from Packagist at the next crawl, which is every push where a hook is installed and weekly where one is not, so the removal is neither immediate nor observable at the moment it is made. And where the package as a whole is going away, Composer’s abandoned property, either true or the name of a recommended replacement, makes Composer warn every user who installs it. That property is package-level, so it cannot say that one version was bad.
This sits below what R-CHG-01 expects a [YANKED] heading to correspond to, because nothing in the registry records the withdrawal for a consumer’s tooling to read, which leaves the changelog as the only place the fact exists. Mark the heading [YANKED], say what replaced the version, and delete the tag. A per-version withdrawal state documented by Packagist, of the kind crates.io and PyPI already have, would retire the gap.
Verified 2026-07-31 against Packagist about, Composer schema, and Composer versions and constraints.