# 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

Source: https://socket.dev/glossary/packagist

Last updated: 2026-10-25

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](https://packagist.org/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](https://socket.dev/glossary/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](https://blog.packagist.com/15-years-of-packagist-over-200-billion-package-installs/)). 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](https://packagist.org/about)). 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](https://getcomposer.org/doc/05-repositories.md)). A private repository listed before Packagist therefore wins for its own package names, which is PHP's main defense against [dependency confusion](https://socket.dev/glossary/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](https://packagist.org/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](https://blog.packagist.com/15-years-of-packagist-over-200-billion-package-installs/)).

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](https://blog.packagist.com/immutable-versions-on-packagist/)).

Compare the [npm registry](https://socket.dev/glossary/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.

> How composer update installs monolog/monolog 3.12.1 (metadata as served on October 25, 2026).
>
> Sequence of requests Composer makes to install monolog/monolog 3.12.1 from Packagist

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](https://socket.dev/glossary/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](https://socket.dev/blog/hidden-blast-radius-of-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](https://getcomposer.org/doc/articles/scripts.md)). 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](https://getcomposer.org/doc/06-config.md)).

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:

```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](https://getcomposer.org/doc/04-schema.md)). 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 path | When it runs | Gate | Real example |
| --- | --- | --- | --- |
| Dependency scripts | Never | Not applicable | None |
| Composer plugin | Every Composer command once installed | allow-plugins, deny by default since 2.2 | intercom/intercom-php 5.0.2, April 2026 |
| autoload.files | Every request that loads vendor/autoload.php | None | laravel-lang packages, May 2026 |
| Laravel provider or file-scope code | App boot or first class load | None | nhattuanbl/lara-helper, March 2026 |
| Bundled package.json hooks | npm install when the package is the project root, as with starter kits | npm's own rules | devdojo/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](https://blog.packagist.com/composer-command-injection-vulnerability/)).

On May 1, 2023, an attacker logged into four inactive Packagist accounts using passwords leaked elsewhere, gaining control of 14 packages ([Packagist](https://blog.packagist.com/packagist-org-maintainer-account-takeover/)). The attacker repointed each package's repository URL to a fork. That was a classic [maintainer account takeover](https://socket.dev/glossary/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](https://socket.dev/blog/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](https://socket.dev/supply-chain-attacks/mini-shai-hulud) campaign, a descendant of the [Shai-Hulud](https://socket.dev/glossary/shai-hulud) npm worm, reached Packagist through the [intercom/intercom-php compromise](https://socket.dev/blog/mini-shai-hulud-packagist-malicious-intercom-php-package-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.

> A Packagist version maps a version string to a Git commit. Immutability fixes that mapping for stable versions; it does not stop new tags.
>
> intercom/intercom-php version 5.0.2 repointed to two attacker commits on April 30, 2026, compared with Packagist behavior after July 7, 2026

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](https://socket.dev/blog/malicious-postinstall-hook-found-across-700-github-repos). 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](https://socket.dev/supply-chain-attacks/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](https://socket.dev/supply-chain-attacks/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](https://socket.dev/blog/polinrider-github-packagist)). 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](https://blog.packagist.com/an-update-on-composer-packagist-supply-chain-security/)).

## What Composer 2.10 and immutable versions changed

Composer 2.10, released May 28, 2026, added dependency policies under `config.policy` ([Packagist](https://blog.packagist.com/composer-2-10-release/)). 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:

| Policy | update / require | composer audit | install from lock |
| --- | --- | --- | --- |
| malware | Blocked | Fails | Blocked |
| advisories | Blocked | Fails | Allowed |
| abandoned | Allowed | Fails | Allowed |

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](https://repo.packagist.org/lists/all/summary.json), 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](https://blog.packagist.com/immutable-versions-on-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:

| Control | Stops | Does not stop |
| --- | --- | --- |
| Explicit allow-plugins list | A library silently converted to a plugin, as in intercom-php | autoload.files, framework auto-discovery, plugins you already allowed |
| Malware policy (Composer 2.10+) | Installing a version that is already flagged, even from an old lock | The window before anyone flags it, clients older than 2.10 |
| Advisories policy (Composer 2.9+) | Updating into versions with known CVEs | Malware, which has no advisory |
| Immutable stable versions (July 2026) | Repointing a published stable version, as in intercom-php and laravel-lang | Brand-new malicious tags; mutable dev-\* branches |
| Committed composer.lock | A moved tag changing an existing install | Fresh 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](https://socket.dev/blog/packagist-urges-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](https://socket.dev/blog/introducing-php-and-composer-support)), and it flagged intercom/intercom-php 5.0.2 as malware 14 minutes after release.

Composer has no built-in [dependency cooldown](https://socket.dev/glossary/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](https://socket.dev/glossary/package-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.
