Back to glossary

Packagist

Packagist is Composer's default PHP package repository: an index of metadata read from Git repositories, with code served as archives from the Git host.

Also known as packagist.org, repo.packagist.org, PHP package repository

Packagist (packagist.org) is the default PHP package repository for Composer, the language's dependency manager. Packagist is a metadata index, not a code host: it reads composer.json and Git tags from each package's source repository, and Composer downloads code as archives the Git host generates at a pinned commit. Whoever controls that Git repository controls what Packagist serves.

As of October 25, 2026, Packagist lists 475,767 packages and 5,978,068 versions, and has served more than 208 billion installs since April 2012 (Packagist statistics). That scale rests on a thin trust model. A Packagist version is a pointer from a version string to a Git commit, and until July 2026 anyone who could push to the source repository could move that pointer. Every 2026 compromise of a legitimate Packagist package we analyzed started in the package's Git repository or GitHub account, not in Packagist.

Our position: Composer's execution model is safer than npm's by default. Composer never runs a dependency's scripts, and plugins need explicit approval. The weak points are elsewhere. Files listed under autoload.files run on every request with no gate, archives carry no registry-held hash, and the new malware blocklist works only after someone flags a version.

What is Packagist?

#

Packagist is the public index that Composer queries by default to turn a name like monolog/monolog into a list of versions and download URLs. Composer is the client, PHP's package manager; Packagist is the server it talks to. Jordi Boggiano registered packagist.org in April 2011, four days after Nils Adermann pushed Composer's first commit (Packagist). Private Packagist is a separate commercial product from the same team for hosting private packages.

Maintainers publish to Packagist by submitting a public repository URL, not by uploading an archive. Packagist reads composer.json from the repository root, turns tags like v3.12.1 into versions and branches into dev-* versions, and recrawls on every push once the GitHub hook is installed (Packagist about page). Package names have two parts, vendor/package. A vendor name is protected once a package under it is published, and only maintainers of an existing package in that vendor can add new ones.

Packagist is the only repository Composer registers by default. Composer checks repositories from top to bottom and, by default, stops at the first one that has the package (Composer docs). A private repository listed before Packagist therefore wins for its own package names, which is PHP's main defense against dependency confusion. Packagist's own malware list includes entries named jav333d/depconf-poc and paysera-test/lib-security-confusion-poc, so people are trying the technique against PHP.

How many packages are on Packagist?

#

Packagist lists 475,767 packages and 5,978,068 versions as of October 25, 2026 (Packagist statistics). Composer pulled 4.9 billion package installs from Packagist in August 2026 alone. Monolog, the first package ever submitted on September 27, 2011, has passed one billion installs (Packagist).

Those installs are not served from Packagist's own storage. Packagist serves metadata, and the code comes from GitHub, GitLab, or Bitbucket. That split is the most important fact about Packagist security.

How does Composer install a package from Packagist?

#

Composer installs a Packagist package in two network hops: metadata from Packagist, then a zip archive from the Git host. It first fetches https://repo.packagist.org/packages.json, a small root document that tells Composer where everything else lives. On October 25, 2026 it advertised metadata-url as https://repo.packagist.org/p2/%package%.json, a security-advisories API, and a filter object with a malware list enabled.

Composer then fetches one metadata file per package. Here is the newest version entry from monolog/monolog.json, abridged:

JSON
{
  "minified": "composer/2.0",
  "packages": {
    "monolog/monolog": [
      {
        "name": "monolog/monolog",
        "version": "3.12.1",
        "source": {
          "type": "git",
          "url": "https://github.com/Seldaek/monolog.git",
          "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15"
        },
        "dist": {
          "type": "zip",
          "url": "https://api.github.com/repos/Seldaek/monolog/zipball/d7059e4c691d05ea1222b5124bed0f961be46e15",
          "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15",
          "shasum": ""
        },
        "require": { "php": ">=8.1", "psr/log": "^2.0 || ^3.0" },
        "time": "2026-09-29T19:21:12+00:00"
      }
    ]
  }
}

Two fields carry the security model. The reference is a Git commit ID, and the dist URL asks GitHub to build a zip of that commit. The shasum is empty. Packagist does not store package archives, so it has no hash of the bytes to give Composer. Packagist notes that GitHub generates these archives on demand and that the same commit is not guaranteed to produce byte-identical archives over time (Packagist).

Compare the npm registry. Its metadata for left-pad@1.3.0 points at a tarball the registry itself hosts, with a sha512 integrity value and a registry signature. On Packagist, the strongest integrity claim is the 40-character SHA-1 commit ID in the download URL.

The figure above traces six steps. Composer requests /packages.json from repo.packagist.org and learns the metadata URL and filter lists. It requests /p2/monolog/monolog.json and learns that 3.12.1 is commit d7059e4 with a zipball URL and an empty shasum. Composer resolves versions locally, dropping any version its policy config blocks. It writes composer.lock with the commit reference. It downloads api.github.com/repos/Seldaek/monolog/zipball/d7059e4…, which GitHub generates on demand. Finally, it unzips into vendor/ and generates vendor/autoload.php. Packagist never handles the bytes that land in vendor/.

composer.lock copies the dist block, including the commit reference and the empty shasum. A lockfile pins a commit, so a moved tag cannot change what composer install fetches from an existing lock. A moved tag changes what the next composer update, composer require, composer create-project, or composer global require resolves. As we wrote after the axios compromise, "Lockfiles make existing installs deterministic. They do not make new installs safe." A clean composer.lock checked after an incident does not prove a CI job or a developer's global tools never resolved the bad version.

Where Composer runs dependency code

#

Composer runs dependency code in two places: when an allowed plugin activates, and when your application loads the autoloader. It never runs a dependency's scripts. The Composer docs are explicit: "Only scripts defined in the root package's composer.json are executed" (Composer docs). Composer does not have npm's default-on postinstall problem.

Plugins are the install-time path. A package with "type": "composer-plugin" ships a class that Composer loads into its own process and that can subscribe to install, update, and download events. Since Composer 2.2, the root project's allow-plugins config decides which plugins may load, and it "Defaults to {} which does not allow any plugins to be loaded" (Composer docs).

We tested that gate offline with Composer 2.10.3 and two local path repositories. The first served acme/sdk 1.0.0 as an ordinary library whose composer.json declared a post-install-cmd script and an autoload.files entry. The second served the same name and version as a composer-plugin, mimicking a library silently converted into a plugin:

JSON
{
  "name": "acme/sdk",
  "version": "1.0.0",
  "type": "composer-plugin",
  "require": { "composer-plugin-api": "^2.0" },
  "autoload": { "psr-4": { "Acme\\": "src/" } },
  "extra": { "class": "Acme\\Plugin" }
}

The plugin class writes a marker file from activate(). Output below is trimmed to the relevant lines:

Plain text
$ composer install --no-interaction          # acme/sdk 1.0.0, type: library
  - Installing acme/sdk (1.0.0): Mirroring from ../lib
$ ls /tmp/demo-dep-script-ran                # the dependency's post-install-cmd
ls: cannot access '/tmp/demo-dep-script-ran': No such file or directory
$ php -r 'require "vendor/autoload.php";' && ls /tmp/demo-autoload-files-ran
/tmp/demo-autoload-files-ran                 # autoload.files ran, no prompt

$ composer update --no-interaction           # same name and version, now a plugin
  - Upgrading acme/sdk (1.0.0 aaaf654 => 1.0.0 52d78f6)
In PluginManager.php line 821:
  acme/sdk contains a Composer plugin which is blocked by your allow-plugins
  config. You may add it to the list if you consider it safe.

$ composer config allow-plugins true && composer install --no-interaction
  - Installing acme/sdk (1.0.0): Mirroring from ../plug
acme/sdk plugin activated

Three results matter. The dependency's script never ran. An unlisted plugin stopped a non-interactive update with an error, while "allow-plugins": true let it run immediately. And the autoload.files entry executed the moment PHP required vendor/autoload.php, with no install-time check involved.

Runtime is the ungated path. Composer documents files autoloading as the way to "require certain files explicitly on every request" (Composer docs). Framework conventions add more: Laravel loads service providers that packages register under extra.laravel.providers, and any PHP file with code at file scope runs when the autoloader first loads its class. For PHP, install time is not the only place to defend. Every web request is an execution point.

Execution pathWhen it runsGateReal example
Dependency scriptsNeverNot applicableNone
Composer pluginEvery Composer command once installedallow-plugins, deny by default since 2.2intercom/intercom-php 5.0.2, April 2026
autoload.filesEvery request that loads vendor/autoload.phpNonelaravel-lang packages, May 2026
Laravel provider or file-scope codeApp boot or first class loadNonenhattuanbl/lara-helper, March 2026
Bundled package.json hooksnpm install when the package is the project root, as with starter kitsnpm's own rulesdevdojo/wave, May 2026

How have attackers abused Packagist?

#

Attackers have abused Packagist mostly by compromising the Git repositories and GitHub accounts it mirrors, then letting Packagist distribute the result. The registry did what it was designed to do in each case.

Packagist's servers run Composer's VCS drivers against every submitted URL, which makes Composer bugs registry bugs. In April 2021, CVE-2021-29472 showed that a crafted Mercurial URL could inject commands, leaving packagist.org "theoretically vulnerable," according to Packagist's advisory; fixes shipped within 12 hours and Packagist found no sign of exploitation (Packagist).

On May 1, 2023, an attacker logged into four inactive Packagist accounts using passwords leaked elsewhere, gaining control of 14 packages (Packagist). The attacker repointed each package's repository URL to a fork. That was a classic maintainer account takeover, and it shipped no malicious code only because the attacker chose not to.

In March 2026 we found malicious Packagist packages disguised as Laravel utilities that deliver an encrypted remote access trojan. nhattuanbl/lara-helper loads its payload from a Laravel service provider on every application boot. nhattuanbl/lara-swagger contains no malicious code and simply requires lara-helper at dev-master.

On April 30, 2026, the Mini Shai-Hulud campaign, a descendant of the Shai-Hulud npm worm, reached Packagist through the intercom/intercom-php compromise. The attacker repointed version 5.0.2 to commit e8a812c at 20:51:09 UTC and again to e69bf4b at 20:53:12 UTC. The new composer.json changed the package type to composer-plugin, and the plugin ran a shell script that downloaded Bun to execute an 11.7 MB obfuscated credential stealer. Intercom later traced the root cause to a local install of a compromised PyPI package, so one credential theft crossed three registries.

The figure above shows that sequence and what changed afterward. Before July 7, 2026, version 5.0.2 of intercom/intercom-php pointed at an earlier commit, then at e8a812c5… from 20:51:09 UTC, then at e69bf4b3… from 20:53:12 UTC: one version string, three commits. After July 7, 2026, Packagist.org keeps a stable version's original commit when its upstream tag moves, marks the version "retag blocked," and emails the maintainers. Lock files stay unaffected. Branch versions such as dev-main can still change, and anyone with write access to the repository can still push brand-new tags.

In May 2026 we found a malicious postinstall hook in eight Packagist packages. The attacker committed it to package.json, not composer.json, where PHP reviewers rarely look. Most affected versions were branch-tracking dev-* versions. The real exposure was in starter kits like devdojo/wave, where the repository becomes the developer's project root and npm install runs the hook directly.

On May 22 and 23, 2026, the Laravel Lang compromise rewrote Git tags across four packages in the Laravel-Lang GitHub organization, backdooring more than 700 existing versions. Each poisoned version registered src/helpers.php under autoload.files, so the backdoor ran on every PHP request and fetched a second-stage credential stealer. No plugin prompt applied. A CVE scanner had nothing to match, because malware does not get a CVE.

From mid-June 2026, the North Korea-linked PolinRider campaign compromised developer GitHub accounts and planted code in the dev-main branch of visanduma/nova-two-factor, a package with more than 700,000 cumulative downloads (Socket). No stable release was poisoned. Packagist simply mirrored the branch.

The common thread is that Packagist trusts the Git repository completely. Packagist's maintainers said the same in May 2026: nearly every recent attack involved modifying existing Git tags after publication (Packagist).

What Composer 2.10 and immutable versions changed

#

Composer 2.10, released May 28, 2026, added dependency policies under config.policy (Packagist). A repository advertises named lists in packages.json, and Composer removes matching versions from the resolver pool. The defaults are on for everyone using packagist.org:

Policyupdate / requirecomposer auditinstall from lock
malwareBlockedFailsBlocked
advisoriesBlockedFailsAllowed
abandonedAllowedFailsAllowed

The malware row is the important one. A version flagged after you locked it fails your next composer install, which closes the gap where a deploy replays a poisoned lock. Packagist's list comes from a third-party malware feed published under CC-BY 4.0. As of October 25, 2026, the summary file Composer reads lists 162 package names, including all four laravel-lang packages and visanduma/nova-two-factor. You opt out with "policy": false, --no-blocking, or COMPOSER_NO_BLOCKING, so check that your CI images do not set them.

On July 7, 2026, Packagist made stable versions immutable. Nils Adermann wrote: "Once a version has been published, the git reference it points to can no longer change" (Packagist). Deleted versions now stay visible with a reason, and blocked retags are recorded in the package's transparency log.

Each control closes one path and leaves others open:

ControlStopsDoes not stop
Explicit allow-plugins listA library silently converted to a plugin, as in intercom-phpautoload.files, framework auto-discovery, plugins you already allowed
Malware policy (Composer 2.10+)Installing a version that is already flagged, even from an old lockThe window before anyone flags it, clients older than 2.10
Advisories policy (Composer 2.9+)Updating into versions with known CVEsMalware, which has no advisory
Immutable stable versions (July 2026)Repointing a published stable version, as in intercom-php and laravel-langBrand-new malicious tags; mutable dev-* branches
Committed composer.lockA moved tag changing an existing installFresh resolves in CI, create-project, global tools

Verdict: keep every row on, because they barely overlap. Immutability, had it shipped earlier, would have blocked both the intercom-php and Laravel Lang retags, but not an attacker with the same access who pushes a new 5.0.3 tag. allow-plugins would have stopped intercom-php's plugin in projects with an explicit list, and done nothing for laravel-lang's autoloaded file. The malware list catches attacks like these only after someone reports them. The only control that covers the first hours of an attack is inspecting what a new version contains before it is installed.

How we would configure Composer today

#

We treat a Packagist version as a claim about a Git commit and verify what changed before installing it. In practice that comes down to four habits.

Keep allow-plugins as an explicit list of package names. Never set it to true, and avoid vendor wildcards like "acme/*": true, which approve any plugin a compromised vendor later ships.

Run Composer 2.10 or later everywhere, including CI images and Dockerfiles, and leave the default policies on. Composer 2.9.8 also fixed a bug that printed GitHub Actions tokens to logs, which we covered when Packagist urged an immediate Composer update.

Keep dev-* constraints out of production. Branch versions are still mutable, and three of the incidents above lived in branch versions.

Review lock diffs for the two execution paths. This script lists every locked package that is a plugin or registers autoloaded files:

php
<?php
$l = json_decode(file_get_contents('composer.lock'), true);
foreach (array_merge($l['packages'], $l['packages-dev'] ?? []) as $p) {
    if (($p['type'] ?? '') === 'composer-plugin') echo "plugin  {$p['name']} {$p['version']}\n";
    foreach ($p['autoload']['files'] ?? [] as $f) echo "files   {$p['name']} $f\n";
}

A package that appears in that output for the first time, or a dist.reference that changes while the version string stays the same, deserves a human look. Socket's scanner applies the same priorities across Packagist: it weights Composer plugin definitions and autoload entry points first (Socket), and it flagged intercom/intercom-php 5.0.2 as malware 14 minutes after release.

Composer has no built-in dependency cooldown as of October 2026. Packagist said in May 2026 that a minimum-release-age policy was blocked until release metadata became reliably immutable, and that precondition now holds.

The archive is still the gap

#

Packagist has fixed what a version means. It has not yet fixed what a version contains. The bytes Composer unpacks still come from an archive endpoint Packagist does not control, and the lock has no hash to check them against. Packagist says hosting immutable artifacts, then provenance and Sigstore attestations, comes next, and that it needs more sponsors to build it. Until that ships, a stable PHP version is a promise about a commit, and every request in production still loads whatever autoload.files names.