--- name: packagist-scraper description: Turn a list of Composer package names into flat structured records via the Apify Actor arman-bd/packagist-scraper. Each record carries the latest version and its release dates, the full require and require-dev maps, autoload configuration, licences, authors and maintainers, the PHP constraint, abandonment status and replacement, source and dist references, security advisory ids, and optional download counts, upstream repository counters and version history. Use when a task needs a PHP dependency audit, an abandonment sweep, a Composer SBOM, PHP-version migration planning, or a ranked view of the most-installed packages. Not for private or self-hosted Composer repositories, package source code, or ecosystems other than PHP. --- # Packagist Scraper Apify Actor `arman-bd/packagist-scraper`. Give it `vendor/package` names, get one dataset record each. With no names at all it falls back to Packagist's own popularity ranking. No credentials are involved. ## When to use it - An abandonment audit: `abandoned`, `abandonedInFavourOf` and a stale `lastRelease` name exactly what to migrate off. - A Composer SBOM: `require`, `requireDev`, `source` and `dist` give the declared graph with commit references. - PHP-version migration planning: `phpRequirement` per release shows when each dependency picked up support for a newer PHP. - Licence and authorship review across a whole `composer.json` in one run. - Ecosystem research: run with no input to pull the most-installed packages, ranked. ## When not to use it - Private Packagist or any self-hosted Composer repository. Public index only. - Package source code, file listings or diffs. Only metadata and reference URLs. - Resolving a dependency tree. You get each package's declared constraints, not a solved lock file; recurse yourself if you need the transitive graph. - Full vulnerability detail. The advisory ids and affected ranges are here, but descriptions, severities and CVE aliases are not. - npm, PyPI, crates.io or any non-PHP ecosystem. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/packagist-scraper').call({ packages: ['monolog/monolog', 'guzzlehttp/guzzle', 'symfony/console'], includeVersionHistory: true, maxVersions: 10, includeStats: true, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); ``` One-shot over HTTP, when you want the rows back in the same request: ```bash curl -X POST "https://api.apify.com/v2/acts/arman-bd~packagist-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"packages":["monolog/monolog","laravel/framework"],"includeVersionHistory":true,"maxVersions":10}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/packagist-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `packages` | string[] | no | `[]` | `vendor/package` names. A Packagist package URL and a `composer require` line with a constraint (`monolog/monolog:^3.0`) are both accepted and reduced to the name. Names are lowercased and de-duplicated. Anything without a slash is dropped before any request. Empty falls back to the popularity ranking. | | `onlyStable` | boolean | no | `true` | Ignore dev, alpha, beta, RC and patch versions when choosing the version each record describes. Off lets a prerelease be `latestVersion`. | | `includeVersionHistory` | boolean | no | `false` | Add the `releases` array. Costs no extra request, since it comes from metadata already downloaded. | | `maxVersions` | integer | no | `20` | Entries kept in `releases`, newest first. Ignored when `includeVersionHistory` is off. Maximum 2000. | | `includeDevBranches` | boolean | no | `false` | Add `devBranches`, the open development branches, same shape as `releases`. One extra request per package. | | `includeStats` | boolean | no | `true` | Add downloads, favers, dependents, suggesters, the upstream repository counters, `maintainers` and `language`. One extra request per package. | | `maxPackages` | integer | no | `25` | Cap on packages looked up per run. Applies to your list and to the popularity fallback alike. Maximum 5000. | **`includeStats` is the switch that decides how a run behaves.** Metadata is one cheap request per package. The statistics come from a separate, busier place, so leaving the switch on roughly doubles both the request count and the wall time, and it is the only part of a run that fails on its own. Everything structural (versions, dependencies, licences, autoload, abandonment, advisories) is on the metadata request and is always present. Turn `includeStats` off for a fast structural sweep of a large list, and on only when you actually need install volume or ecosystem reach. ## Output One record per package that resolved. | Field | Type | Notes | |---|---|---| | `name` | string | Canonical `vendor/package`. | | `vendor` | string | The part before the slash. Group on this. | | `packageName` | string | The part after the slash. | | `description` | string \| null | Package summary. | | `type` | string \| null | Composer type: `library`, `symfony-bundle`, `composer-plugin` and so on. | | `keywords` | string[] | Declared tags, empty when none. | | `homepage` | string \| null | Project site as declared. | | `repository` | string \| null | Upstream repository. Falls back to the source URL when statistics were not fetched. | | `language` | string \| null | Primary language, from the statistics request. `null` when `includeStats` is off. | | `latestVersion` | string | The version this record describes, subject to `onlyStable`. | | `latestVersionNormalized` | string \| null | Composer's normalised form, for example `3.10.0.0`. Sortable. | | `lastRelease` | string \| null | Tag time of `latestVersion`, ISO 8601 with offset. | | `firstRelease` | string \| null | Tag time of the oldest version in the filtered set, so with `onlyStable` on it is the oldest stable tag, not the oldest tag. | | `releaseCount` | number | Every tagged version, prereleases included, regardless of `onlyStable`. | | `stableReleaseCount` | number | Tagged versions that are stable. | | `license` | string[] | Declared SPDX identifiers. Empty array when nothing is declared. | | `authors` | object[] | Keys `name`, `email`, `homepage`, `role`, each nullable. | | `maintainers` | string[] | Packagist account names, from the statistics request. Empty when `includeStats` is off. | | `phpRequirement` | string \| null | The PHP constraint alone, lifted out of `require`. | | `require` | object | Full `{ package: constraint }` map. Empty object when none. | | `requireDev` | object | Full dev dependency map. | | `suggest` | object | `{ package: reason }` map. | | `autoload` | object | PSR-4, PSR-0, classmap and files configuration as declared. | | `abandoned` | boolean | True when the package is marked abandoned, with or without a replacement. | | `abandonedInFavourOf` | string \| null | The suggested replacement, when the author named one. `null` when abandoned with no suggestion, and when not abandoned. | | `source` | object \| null | Repository reference, keys `url`, `type`, `reference` (the commit). | | `dist` | object \| null | Downloadable archive, keys `url`, `type`, `shasum`. | | `support` | object | Issue tracker, docs and chat links as declared. | | `funding` | object[] | Funding channels, each `{ url, type }`. | | `securityAdvisories` | object[] | Advisory entries shipped with the metadata, each carrying an advisory id and the affected version range. Empty array when there are none. | | `downloadsTotal` | number \| null | All-time installs. `null` when `includeStats` is off or the statistics request failed. | | `downloadsMonthly` | number \| null | Installs in the last 30 days. | | `downloadsDaily` | number \| null | Installs in the last day. | | `favers` | number \| null | Packagist favourites. | | `dependents` | number \| null | Packages that require this one. | | `suggesters` | number \| null | Packages that suggest this one. | | `githubStars` | number \| null | Upstream stars. | | `githubForks` | number \| null | Upstream forks. | | `githubOpenIssues` | number \| null | Upstream open issues. | | `releases` | object[] | Present only when `includeVersionHistory` is on. Newest first, keys `version`, `versionNormalized`, `releasedAt`, `publishedAt`, `prerelease`, `phpRequirement`, `sourceReference`. | | `devBranches` | object[] | Present only when `includeDevBranches` is on and the request succeeded. Same shape as `releases`. | | `scrapedAt` | string | Run timestamp, ISO 8601 UTC. | A real record, long strings trimmed: ```json { "name": "monolog/monolog", "vendor": "monolog", "packageName": "monolog", "description": "Sends your logs to files, sockets, inboxes, databases and various web services", "type": "library", "keywords": ["log", "logging", "psr-3"], "homepage": "https://github.com/Seldaek/monolog", "repository": "https://github.com/Seldaek/monolog", "language": "PHP", "latestVersion": "3.10.0", "latestVersionNormalized": "3.10.0.0", "lastRelease": "2026-01-02T08:56:05+00:00", "firstRelease": "2011-07-07T16:21:02+00:00", "releaseCount": 87, "stableReleaseCount": 83, "license": ["MIT"], "authors": [{ "name": "Jordi Boggiano", "email": "j.boggiano@seld.be", "homepage": "https://seld.be", "role": null }], "maintainers": ["Seldaek"], "phpRequirement": ">=8.1", "require": { "php": ">=8.1", "psr/log": "^2.0 || ^3.0" }, "requireDev": { "phpunit/phpunit": "^10.5.17 || ^11.0.7" }, "suggest": { "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server" }, "autoload": { "psr-4": { "Monolog\\": "src/Monolog" } }, "abandoned": false, "abandonedInFavourOf": null, "source": { "url": "https://github.com/Seldaek/monolog.git", "type": "git", "reference": "b321dd67…" }, "dist": { "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd67…", "type": "zip", "shasum": "" }, "support": { "issues": "https://github.com/Seldaek/monolog/issues" }, "funding": [{ "url": "https://github.com/Seldaek", "type": "github" }], "securityAdvisories": [{ "advisoryId": "PKSA-dmw8-jd8k-q3c6", "affectedVersions": ">=1.8.0,<1.12.0" }], "downloadsTotal": 1036088143, "downloadsMonthly": 18121188, "downloadsDaily": 772059, "favers": 22261, "dependents": 8416, "suggesters": 633, "githubStars": 21396, "githubForks": 1907, "githubOpenIssues": 32, "scrapedAt": "2026-08-06T11:52:18.911Z" } ``` ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** It is where a partial run admits that it was partial, and it holds three separate reject lists that mean different things. ```json { "packagesRequested": 3, "packagesSaved": 2, "packagesFailed": 1, "failures": [ { "package": "acme/nope", "error": "package not found on Packagist (404)" }, { "package": "symfony/console", "error": "stats: rate limited (429)" } ], "unknownPackages": ["acme/nope"], "malformedNames": ["monolog"], "packageSource": "input", "filters": { "onlyStable": true, "includeVersionHistory": true, "maxVersions": 10, "includeDevBranches": false, "includeStats": true, "maxPackages": 25 }, "finishedAt": "2026-08-06T11:52:19.400Z" } ``` `packagesFailed` counts only the names that did not exist, so it is *not* the length of `failures`: a package whose statistics request failed appears in `failures` yet is still saved, with the statistics fields `null`. Read the three reject lists separately. `malformedNames` never reached a request at all, `unknownPackages` did and were not found, and anything else in `failures` is either a stale-only package with no stable release or a statistics miss on a record you did have. `packageSource` tells you whether the run used your list (`input`) or the fallback ranking (`popular-ranking`), which is how you catch an input array that was silently empty. ## Behaviour to plan around - **An empty `packages` array does not fail, it changes the job.** The run silently switches to the popularity ranking and returns the most-installed packages instead of yours. Check `packageSource` before treating the output as your list. - **`releases` and `devBranches` are absent, not null, when not requested.** The keys do not appear in the record at all. Test for presence, not for `null`. - **Statistics fail independently and quietly.** A statistics miss leaves the package saved with every download and repository counter `null`, and only `failures` records why. Do not read a `null` `downloadsTotal` as zero installs. - **`releaseCount` ignores `onlyStable`, `firstRelease` does not.** The count is every tag ever; the first-release date is the oldest tag in the filtered set. A package with a long prerelease history will look younger than it is when `onlyStable` is on. - **A package with no stable release is dropped entirely** when `onlyStable` is on, with `no stable release` in `failures`. Set `onlyStable` to `false` to see it. - **`abandoned` and `abandonedInFavourOf` are independent.** An abandoned package with no suggested replacement has `abandoned: true` and `abandonedInFavourOf: null`, so never infer abandonment from the replacement field alone. - **Version metadata is served in a compacted form and is expanded here.** Read raw, any version other than the newest would appear to have lost its licence, authors and dependencies. Every entry in `releases` is fully resolved, so those fields are trustworthy at any point in the history. - **Versions are newest first** in `releases` and `devBranches`. Index 0 is the latest tag, never the oldest, and `maxVersions` counts back from the newest. - **Requests are deliberately spaced** and made one package at a time, so run time scales with `maxPackages` times the number of switches you enabled. - **Transient failures are retried** three times, waiting longer when the source asks for it. A missing package is final and is not retried. - **The run only throws when nothing was saved and every lookup failed.** ## Recipes **Abandonment and staleness audit.** Structural fields only, so it stays fast on a long list. ```json { "packages": ["laravel/framework", "swiftmailer/swiftmailer", "phpunit/phpunit"], "includeStats": false, "maxPackages": 200 } ``` Flag any record with `abandoned` true, then anything whose `lastRelease` is more than a year old. `abandonedInFavourOf` is the migration target where one exists. **Composer SBOM for a lock file.** Everything structural plus history. ```json { "packages": ["monolog/monolog", "guzzlehttp/guzzle", "symfony/console"], "includeVersionHistory": true, "maxVersions": 50, "onlyStable": true } ``` `require` and `requireDev` are the declared edges; `source.reference` and `dist.url` pin the artefact. Recurse over the keys of `require` to walk the transitive graph. **PHP upgrade planning.** History is what makes this work. ```json { "packages": ["symfony/console", "doctrine/orm", "twig/twig"], "includeVersionHistory": true, "maxVersions": 100, "includeStats": false } ``` For each package, find the oldest entry in `releases` whose `phpRequirement` admits your target PHP version. That is the minimum you have to upgrade to. **Ecosystem snapshot.** No package list, so the popularity ranking drives the run. ```json { "includeStats": true, "maxPackages": 100 } ``` Sort on `downloadsMonthly` and `dependents`. Confirm `packageSource` is `popular-ranking` before publishing anything from it.