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. On every install or update. 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 mirrors the FriendsOfPHP database (plus data from the GitHub Advisory Database), 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's advisory API aggregates several upstream sources, including the FriendsOfPHP database, the GitHub Advisory Database, Drupal's SA feed, and a handful of others. 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 covering FriendsOfPHP, GitHub, Drupal SA, and the rest. For private Composer repositories 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.

Filter lists

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 will introduce "filter lists", tracked in composer/composer#12786, to handle this as a general abstraction. A filter list is a named set of entries, each consisting of a package name, a version constraint, and an optional reason and URL. When filtering 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.

The vocabulary introduced by the issue separates three concerns:

  • Sources are data providers. By default every configured Composer repository is a source. Additional HTTP sources can be configured in composer.json.
  • Lists are named groups within a source. A Composer repository can advertise multiple lists (for example aikido-malware alongside other curated sets), and can mark some as default, enabled without further configuration, while leaving others opt-in.
  • 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:

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

The repository's top-level packages.json declares which lists it supports and which are on by default:

{
    "filter": {
        "metadata": true,
        "lists": ["aikido-malware"],
        "default-lists": ["aikido-malware"]
    }
}

Third parties can plug in their own sources. A URL configured under filter.sources receives a POST with a list of package URLs (pkg://composer/vendor/name format) and returns matching filter entries in the same shape. 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 needing to host a full Composer repository.

Filter lists are disabled with "config": {"filter": false} in composer.json, or with COMPOSER_FILTER=0. The same per-entry ignore mechanism used for advisories will, per the design note, be extended to filter entries.

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

## The interaction with roave/security-advisories and FriendsOfPHP

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 GitHub, Drupal SA, and others) 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 configure the affected versions to be 12.5.21 and 13.1.5.
  3. GitHub officially publishes the security advisory here. In doing so, they change the affected versions to <= 12.5.21 and >= 13.0.0, <= 13.1.5.
  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, including versions of PHPUnit 11, for example, that are not affected by the security issue.
  6. 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.
  7. I send a pull request for FriendsOfPHP/security-advisories with the correct "affected versions" information for this issue. Nils Adermann accepts this pull request.
  8. Packagist imports the security advisory from FriendsOfPHP/security-advisories.
  9. PHPUnit 11, for example, can be installed using Composer 2.9 again.

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 ignore form with the apply: "all" scope and a written reason.

For CI pipelines that need temporary bypass during an incident, use COMPOSER_NO_SECURITY_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.

For abandoned-package blocking, consider turning it on ("block-abandoned": 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.