A bouncer in a black suit, earpiece in, hand raised in a firm "stop" gesture at the door of a club. Behind him, a queue of hopeful guests waits behind a golden velvet rope. A visual metaphor for a gatekeeper deciding who gets in and who does not.

For a long time, Composer's handling of security advisories was informational rather than enforcing: composer audit printed a nicely formatted table of advisories after an install completed, but the resolver itself did not check whether a version was vulnerable.

If you wanted a hard guarantee that a known-bad version could not land in your vendor directory, you had to rely on a third-party package, usually roave/security-advisories. And trust that its list of conflict rules was current.

Composer 2.9 changed that. The resolver itself now refuses to consider versions with known advisories. By default. Every time the dependency graph is resolved. Composer 2.10 will extend the same machinery to malware-flagged versions using a more general "filter list" concept.

This article walks through how each mechanism actually works, how they relate to the older packages they replace, and where the new behaviour can bite you in ways the old passive audit never did.

Conflict rules as a security tool

The pre-2.9 approach to blocking vulnerable installs was a clever abuse of Composer's existing conflict resolution. Two repositories have historically driven it.

FriendsOfPHP/security-advisories is the raw data: a public-domain YAML database of PHP security issues, one file per advisory, organised by Composer package name. Each file lists affected branches and version constraints in Composer's own syntax (['>=2.0.0', '<2.0.17']). The contribution guide explicitly describes the branches structure, where each branch has a time field for when the issue was fixed and a versions array listing affected version constraints. It is, by its own documentation, not meant to be authoritative. It is a convenience aggregator of information that lives elsewhere (vendor advisories, CVE records, GitHub).

Roave/SecurityAdvisories turns that data into something the Composer resolver will act on. It ships a single composer.json whose conflict section is fed from two upstream sources — the FriendsOfPHP database and the GitHub Advisory Database — and rebuilt hourly. You install it as roave/security-advisories:dev-latest in require-dev, and from then on any attempt to add or update to an affected version fails with a regular "conflicting constraints" error. The package itself contains no code: its entire purpose is to exist as a set of conflicts in the dependency graph.

This approach has real limitations. It runs only on composer require and composer update. composer install from a valid lock file intentionally does nothing, which is correct behaviour for reproducible deploys but means a lock file pinned before an advisory was published will keep installing the vulnerable version forever. It can only be required at the project root. It cannot be configured per-advisory. There is no "ignore this one because it does not apply to us" switch. And the error message, when it fires, looks like an ordinary version conflict rather than a security notice, which makes triage slower than it needs to be.

Reporting without enforcement

Composer has had a audit command since version 2.4. It reads your composer.lock, queries the Packagist advisory API for every installed package, and prints a report.

Packagist.org's advisory API aggregates two upstream sources: the FriendsOfPHP database and the GitHub Advisory Database. Other collections, for example Drupal's Security Announce feed, are not imported directly; they reach end users through separate Composer repositories such as packages.drupal.org, because every Composer repository may declare in its packages.json that it offers advisory data. Drupal SA entries that already live in FriendsOfPHP or the GitHub Advisory Database therefore still flow through Packagist.org. Advisories without a CVE or GHSA identifier get a synthetic PKSA- prefix assigned by Packagist so they can be referenced consistently. The same data stream powers both the audit command and, as of Composer 2.9, the enforcement mechanism below.

Automatic Security Blocking

Automatic Security Blocking, released in Composer 2.9, moves advisory enforcement from a separate package into the resolver itself. The pull request that introduced it is composer/composer#11956.

The mechanism is a new stage in the dependency resolution pipeline called the SecurityAdvisoryPoolFilter. Composer builds a candidate pool of every version of every package that could potentially satisfy the constraints in composer.json, then runs a SAT solver over that pool to pick an installable set. The new filter runs between those two steps: after the pool is built, before the solver starts, it asks each repository for security advisories covering the packages in the pool, matches them against each candidate version, and removes any version that is affected. The solver then sees a world in which the vulnerable versions simply do not exist.

Verbose output from the pull request makes the stages visible:

Built pool.
Running security advisory pool filter.
Security advisory pool filter completed in 0.001 seconds
Found 105 package versions referenced in your dependency graph.
1 (1%) were filtered away.
Running pool optimizer.

When the resulting solver problem is unsatisfiable, because every version of some required package has been filtered out, Composer does not pretend it is a generic constraint failure. The error spells out exactly what happened:

Root composer.json requires doctrine/cache <=1.3.0,>=1.0.0,
found doctrine/cache[v1.0, v1.1, v1.2.0, v1.3.0] but these were not
loaded, because they are affected by security advisories.

To ignore the advisories, add ("PKSA-5g3y-77s3-wsh1") to the audit
"ignore" config. To turn the feature off entirely, you can set
"block-insecure" to false in your "audit" config.

Two things are worth noting about the scope of this feature.

First, it only runs on commands that resolve the dependency graph: update, require, remove. composer install from a lock file is untouched, exactly as Christophe Coevoet argued during review: filtering at install time would break the lock file's reproducibility guarantee the moment an advisory landed, silently re-resolving the graph on machines that were only meant to replay it. This is a deliberate design choice, not an oversight.

Second, the filter relies on advisories exposed by the configured repositories. For Packagist.org this means the aggregated feed of FriendsOfPHP and the GitHub Advisory Database. For other Composer repositories — whether public (such as packages.drupal.org) or private — it means whatever the repository itself advertises.

Because the feature lives in the resolver, it subsumes what roave/security-advisories was doing. The Composer 2.9 release notes say so directly: "if you are currently using roave/security-advisories to block packages with known vulnerabilities, this feature replaces it fully and you can remove the dependency on this package."

Configuration

Blocking is controlled by a new audit section of the config object in composer.json. The three settings that matter most are:

  • block-insecure (default true)
    Setting this to false reverts to 2.8-era behaviour where advisories are reported but do not block.
  • block-abandoned (default false)
    Opt-in blocking of packages that have been marked abandoned on Packagist. Not on by default, because abandonment is a much softer signal than a published CVE.
  • ignore
    A map or list of advisory IDs to suppress. Advisories can be referenced by CVE ID, GHSA ID, or Packagist's PKSA ID. The map form lets you attach a reason, which matters in practice because suppressed advisories still appear in audit reports and you want the next maintainer to know why you suppressed them.
{
  "config": {
    "audit": {
      "ignore": {
        "CVE-2026-xxxxx": "Windows-only; we deploy Linux",
        "GHSA-xxxx-xxxx-xxxx": "Patched inline via vendor override"
      }
    }
  }
}

Two command-line escape hatches exist. --no-security-blocking on a single invocation and the COMPOSER_NO_SECURITY_BLOCKING environment variable both disable blocking for that run. Notably, these are distinct from --no-audit and COMPOSER_NO_AUDIT, which only suppress the audit report.

In Composer 2.10, the configuration keys described here move into a unified config.policy object that covers advisories, malware, abandonment, and arbitrary custom lists under one consistent skeleton (see the "Filter Lists and Policy" section below). The existing config.audit.* keys continue to work as a fallback; a deprecation warning whenever they are read is planned for Composer 2.11.

Filter Lists and Policy

Security advisories are not the only reason you might want the resolver to refuse a specific version. Recent waves of supply-chain attacks on the JavaScript ecosystem, for example, made it clear that compromised or outright malicious releases need a separate data channel. A CVE implies a known weakness in code that the authors intended. A malware flag implies that the artefact itself is a trap laid by an attacker who got hold of publishing credentials. Both should block resolution, but they come from different feeds and have different trust models.

Composer 2.10 generalises 2.9's pool-filtering stage into a unified config.policy object. It covers security advisories, malware detection, abandoned packages, and arbitrary custom filter lists under one consistent skeleton. The feature was specified in composer/composer#12786. Its first iteration shipped as config.filter in composer/composer#12766. It was then replaced by the unified config.policy object described here in composer/composer#12804. When the feature is enabled (which it is by default), Composer removes any pool version matching any entry in any enabled list, using the same pool-filtering stage that 2.9 built for advisories.

Three lists are built into Composer: advisories for security advisories, malware for versions flagged as malware, and abandoned for packages marked as abandoned on Packagist. Every list, built-in or custom, follows the same pattern:

{
    "block": bool,                    // remove matching versions from the pool?
    "audit": "ignore|report|fail",    // how `composer audit` treats matches
    "ignore": { ... }                 // per-package exemptions (universal format)
}

The defaults match expectations: advisories and malware block and fail audit; abandoned is reported by audit but does not block. The malware list, by default, blocks not only during update/require/remove but also during composer install. That is configurable through malware.block-scope, which takes "all" (the default), "update", or "install". Setting a list to false is shorthand for {"block": false, "audit": "ignore"}; setting it to true is shorthand for the list's defaults. Setting "policy": false disables the feature entirely.

The vocabulary separates three concerns:

  • Sources are data providers. Built-in lists get their data internally: advisories through the AdvisoryProviderInterface, abandonment from package metadata. A Composer repository is a source for the malware list or for a custom list if, and only if, it advertises that list in its packages.json. Custom lists can additionally, or exclusively, be fed from explicit HTTP endpoints configured in composer.json.
  • Lists are the named sets. advisories, malware, and abandoned are built in. Any other key under policy defines a custom list.
  • Entries are the individual filter rules inside a list.

Packagist.org's first production use of this is integration with Aikido's malware feed, as implemented in composer/packagist#1681. When a package version is flagged as malware on Packagist, the metadata file served for that package grows a filter block under the well-known list name malware:

{
  "filter": {
    "malware": [{
      "constraint": "1.0 || 1.1 || 1.2",
      "url": "https://packagist.org/packages/vendor/name/filters",
      "reason": "Package contains malicious code",
      "id": "PKFE-xxxx-xxxx-xxxx",
      "source": "aikido"
    }]
  }
}

The repository's top-level packages.json declares which lists it supports. metadata: true signals that per-package metadata files may carry filter blocks. lists is a map from list name to an object with an enabled flag:

{
  "filter": {
    "metadata": true,
    "lists": {
      "malware": {"enabled": true}
    },
    "summary-url": "/p2/filter-summary.json"
  }
}

A repository can also optionally advertise a summary-url or an api-url. The summary-url returns a compact mapping of list name to package name to version constraint, which Composer fetches on composer audit and composer install. On composer update Composer does not need it, because update loads per-package metadata over the p2/ HTTP calls anyway and those files already carry the filter entries. The api-url accepts a POST with the relevant package PURLs and configured list names and returns matching filter entries directly. It is intended for cases where the summary would be too large to serve in full. If both are set, api-url takes precedence.

Reserved names may not be advertised by a repository or defined by a user as a custom list under config.policy. For the built-in lists, advisories and abandoned are reserved. malware is explicitly not reserved as a built-in name. It is a well-known name that repositories are expected to advertise — but users may not redefine it as a custom list. Beyond those, a number of names are reserved for future built-ins: package, packages, license, licence, licenses, licences, support, maintenance, security, minimum-release-age, and anything starting with ignore (the only ignore-prefixed key allowed under policy is ignore-unreachable). The authoritative list lives in the Custom Lists section of the Composer documentation.

On the client side, each repository entry in composer.json can carry its own filter block. It can either turn off the repository as a filter source entirely ("filter": false) or selectively disable individual lists the repository advertises:

{
  "repositories": [
    {
      "type": "composer",
      "url": "https://example.org",
      "filter": {
        "untrusted-list": false
      }
    }
  ]
}

Third parties can plug in their own sources by defining a custom list under config.policy and supplying one or more sources entries with type: "url". The data can come from a Composer repository that advertises a list of the same name, from explicit sources URLs, or both. If both are present, the entries are merged.

{
  "config": {
    "policy": {
      "company-policy": {
        "sources": [
          {"type": "url", "url": "https://example.org/filter-list.json"}
        ],
        "block": true,
        "audit": "fail"
      }
    }
  }
}

Source URLs must use the https:// scheme. There is no secure-http-style opt-out: policy data drives blocking and audit reporting, so the transport must be authenticated and tamper-resistant. Composer sends a POST to the configured URL with the relevant package PURLs and the names of the lists assigned to this source, in a JSON body of the form {"packages": ["pkg://composer/vendor/name", …], "lists": ["company-policy"]}. The response returns matching filter entries in the same shape the repository metadata uses. This makes it possible for an organisation to run an internal malware or policy feed (for licensing violations, known-bad forks, or packages under quarantine during an incident, for example) without having to host a full Composer repository. config.policy.ignore-unreachable controls whether unreachable repositories and policy sources abort the operation or are silently skipped. The default is ["update", "install"], which means composer audit fails hard if a source is unreachable.

Selective exemptions are expressed through each list's universal ignore field. The simple form is a package name (wildcards such as vendor/legacy-* are supported) with a free-text reason as the value. The detailed object form adds an optional constraint and the booleans on-block and on-audit (both default true). A package can thus be exempted from audit reports while still being blocked on update, or vice versa.

{
  "config": {
    "policy": {
      "malware": {
        "ignore": {
          "vendor/package": "all versions, all contexts",
          "vendor/legacy-*": {"on-audit": false, "reason": "allow install but keep blocking in audit"},
          "vendor/pinned": {"constraint": "^2.0", "reason": "only v2 still in use"}
        }
      }
    }
  }
}

The advisories list adds two specific fields: ignore-id, keyed by CVE, GHSA, or PKSA ID with the same value shapes as the universal format, and ignore-severity, keyed by low, medium, high, and critical. The malware list adds ignore-source, an array of source/repository names whose malware data should be skipped. This is useful when one source is too noisy.

How a list affects the audit report is controlled by that list's audit field, which takes ignore, report, or fail. fail means a match ends an audit run with a non-zero exit code. report reports without failing. ignore stays silent.

The whole policy feature is disabled with "config": {"policy": false} in composer.json, or with COMPOSER_POLICY=0. Individual lists can be turned off the same way by setting their value to false. For a single run, --no-blocking (or COMPOSER_NO_BLOCKING=1) disables all blocking for that one invocation without suppressing the audit report. The older flag --no-security-blocking and its environment variable COMPOSER_NO_SECURITY_BLOCKING remain as deprecated aliases. In 2.10 they no longer disable advisory blocking only; they disable everything, to stay consistent with the help text.

The config.audit.* keys introduced in Composer 2.9 continue to work in 2.10 as a fallback for projects that have not yet migrated to config.policy. The fallback is decided per built-in list, and the two lists advisories and abandoned are independent of each other. As soon as anything is set under policy.advisories, every advisory-related audit.* key (block-insecure, ignore, ignore-severity) is ignored entirely; policy.abandoned is untouched and can still be driven from audit.*. The mirror image holds: any entry under policy.abandoned displaces the abandonment-related audit.* keys but leaves policy.advisories alone. Mixing within the same list — for example, setting policy.advisories.block while expecting audit.ignore-severity to still apply — is not supported. Migration support, including a deprecation warning whenever any audit.* key is read, is expected to ship with Composer 2.11.

Architecturally, this is the same pool-filtering stage doing several jobs: advisories are one data type the filter consumes, malware flags are another, abandonment is a third, and future categories will slot into the same pipeline.

Where does the older stack fit?

FriendsOfPHP/security-advisories still exists and still matters, but now as an upstream data source rather than a direct input to your project. Packagist.org ingests it (alongside the GitHub Advisory Database) into the advisory API. Composer 2.9's pool filter reads that API. You get the same coverage without a dependency in your composer.json. Contributors to FriendsOfPHP are still improving the data, but that work now reaches end users through the resolver instead of through a rebuilt conflict list.

Roave/SecurityAdvisories is explicitly described as superseded. Keeping it installed alongside 2.9's blocking is harmless. The two mechanisms agree on almost all versions. But it adds a dependency, slows resolution slightly, and offers nothing the built-in feature does not. The migration is a one-line removal.

The one thing Roave's package still does better is a narrow edge case: blocking at composer require time with a stale data set, on a machine whose Composer version is older than 2.9. If you cannot upgrade Composer, the old package remains the best option. Everywhere else, the recommendation is the one in the Composer 2.9 release notes: remove it.

A real world example

A useful illustration of the feature's behaviour, and one of its sharp edges, is what happened today with regard to a security vulnerability in PHPUnit 12.5.21 and PHPUnit 13.1.5 I published yesterday. This morning, I received reports via email, chat messages, and phone calls as well as here and here on PHPUnit's issue tracker stating that PHPUnit 11 and earlier versions could no longer be installed.

The sequence that produced this is worth working through, because it reveals how the blocking feature composes with normal constraint resolution:

  1. I release PHPUnit 12.5.22 and PHPUnit 13.1.6 with the security fix.
  2. I publish information about the security vulnerability fixed in PHPUnit 12.5.22 and PHPUnit 13.1.6 here. As only PHPUnit 12.5.21 and PHPUnit 13.1.5 were affected by the issue, I record 12.5.21 and 13.1.5 as the affected versions.
  3. GitHub officially publishes the security advisory in the GitHub Advisory Database.
  4. Packagist imports the security advisory from the GitHub Advisory Database.
  5. Composer 2.9 starts blocking the installation of all PHPUnit versions with a version number lower than 12.5.22. This includes all versions of PHPUnit 11, even though none of them contain the vulnerability.
  6. Reports start reaching me that, for example, PHPUnit 11 can no longer be installed. The cause is quickly identified: GitHub has, for no apparent reason and without consulting me, changed the affected versions to <= 12.5.21 and >= 13.0.0, <= 13.1.5.
  7. I create this pull request for the GitHub Advisory Database to correct the affected-versions information.
  8. I have a discussion with Nils Adermann and Jordi Boggiano, the maintainers of Composer and Packagist, about this situation and learn that data from FriendsOfPHP/security-advisories has precedence over data from the GitHub Advisory Database.
  9. I send a pull request for FriendsOfPHP/security-advisories with the correct affected-versions information for this issue, which Nils Adermann accepts shortly afterwards.
  10. Packagist imports the security advisory from FriendsOfPHP/security-advisories.
  11. PHPUnit 11, for example, can now be installed using Composer 2.9 again. At least when roave/security-advisories is not used.

Although this episode cost me part of my weekend, it demonstrates that the Automatic Security Blocking introduced in Composer 2.9 works as intended.

I hold no grudge against the users who reported the issue on a Saturday, and I hope Nils and Jordi will forgive me for asking them for help on one. What does frustrate me is that GitHub altered the "affected versions" information I had carefully provided when they published my advisory.

It was, of course, this very situation that prompted me to write and publish this article on a Saturday.

Practical guidance

For new projects, do nothing. Blocking is on by default, the defaults are sensible, and the error messages point to the configuration keys you need.

For existing projects still using roave/security-advisories, drop it from require-dev on your next Composer update. Composer's native features are equivalent or better.

For projects where an advisory blocks an update that you cannot immediately take, because the fix is in a major version you have not yet migrated to, or because the advisory does not apply to your usage, use the detailed form under config.policy.advisories.ignore-id with the identifier to suppress, an optional constraint, and a written reason. Projects that have not yet migrated to config.policy can keep using config.audit.ignore for the time being.

For CI pipelines that need temporary bypass during an incident, use COMPOSER_NO_BLOCKING=1 rather than editing composer.json. It is visible in the build log, it does not persist, and it does not get accidentally committed. The older variable COMPOSER_NO_SECURITY_BLOCKING remains as a deprecated alias and does the same thing in 2.10.

For abandoned-package blocking, consider turning it on ("config": {"policy": {"abandoned": {"block": true}}}) once your project is clean. It produces more false positives than advisory blocking does, but it also surfaces supply-chain risk that nothing else in the toolchain will tell you about.

For maintainers of private Composer repositories, the metadata format for both advisories (from 2.9) and filter lists (from 2.10) is documented and stable. Exposing internal security data through it is the correct integration path, and is what Private Packagist does for its customers.

Conclusion

What Composer 2.9 did is narrow on paper. insert one filtering stage into the dependency resolver, but its practical effect is to move a piece of supply-chain security from an opt-in third-party package into the default install path for every PHP project. Composer 2.10 will generalise the same mechanism so that malware flags, and whatever category comes next, can plug into it without reinventing the plumbing.

The old data sources did not go away. FriendsOfPHP's YAML is still the raw material behind a large fraction of the advisories the new system enforces, it simply reaches end users through a different delivery mechanism now. Roave's package did its job for a decade, and part of that job was demonstrating that the ecosystem wanted this behaviour by default. Which, finally, it has.

Update from April 19, 2026
Added more details to the GHSA-qrr6-mg7r-m243 timeline.
Incorporated feedback from Nils Adermann and Jordi Boggiano.

Update from April 20, 2026
Incorporated feedback from Christophe Coevoet.

Update from April 21, 2026
Prompted by the events described above, I completely revised the security policy for PHPUnit.

Update from May 21, 2026
The section "Filter Lists and Policy" has been updated following the release of Composer 2.10.0-RC2 to reflect the current status.

Update from May 21, 2026
Incorporated feedback from Stephan Vock.