spatie/packagist-api
Spatie's PHP package makes it easy to search and fetch package info from the Packagist API. Pass a Guzzle client and URL generator to the client, then list package names by type or vendor, fetch popular packages with pagination, and more.
Install and bind it once — there is no service provider. This is a framework-agnostic client: composer require spatie/packagist-api gives you a PackagistClient whose constructor wants a Guzzle client and a PackagistUrlGenerator. Nothing auto-registers, there's no config file, and there's no facade. Register a singleton in AppServiceProvider with sane HTTP defaults so the rest of your app never constructs it:
composer require spatie/packagist-api
use GuzzleHttp\Client;
use Spatie\Packagist\PackagistClient;
use Spatie\Packagist\PackagistUrlGenerator;
// AppServiceProvider::register()
$this->app->singleton(PackagistClient::class, fn () => new PackagistClient(
new Client([
'timeout' => 5,
'connect_timeout' => 2,
'headers' => ['User-Agent' => config('app.name').' ('.config('app.url').')'],
]),
new PackagistUrlGenerator(),
));
The third-party Laravel wrapper (markwalet/laravel-packagist) exists but predates the 2.0 Packagist → PackagistClient rename and the API overhaul. A six-line binding is cheaper than an extra dependency you have to babysit.
Where to look first. Read packagist.org/apidoc next to the README — the client's methods map almost 1:1 to endpoints, and the API doc tells you what the returned arrays actually hold, which the README never does. Then skim PackagistClient (public methods are the product surface) and PackagistUrlGenerator (your extension seam, see below). Skim the changelog for 2.1.1 too, because it silently changed the metadata response shape.
First use case: a dependency inventory Artisan command. Parse composer.lock, loop your packages, and print licenses, latest version, download counts and abandoned flags. It's small, immediately useful, and exercises the four methods you'll actually live in.
$lock = json_decode(file_get_contents(base_path('composer.lock')), true);
foreach ($lock['packages'] as $package) {
$meta = $packagist->getPackageMetadata($package['name']); // repo endpoint, fast
$stats = $packagist->getPackageDownloadStats($package['name']); // total/monthly/daily
}
Wrap the client in your own service. Never inject PackagistClient into ten controllers and commands. Create App\Support\Packagist\PackageInfo (or similar) that owns caching, exception handling, and maps the raw arrays into DTOs. That wrapper is what you mock in tests, and it's the single place you add throttling if you ever need it.
Cache every read, keyed by normalized name. Packagist caches API responses for 12 hours server-side, but that doesn't save you the HTTP round trip. Lowercase the package name for cache keys so Spatie/Packagist-Api and spatie/packagist-api don't create duplicate entries:
public function metadata(string $name): array
{
return Cache::remember(
'packagist:meta:'.strtolower($name),
now()->addHours(6),
fn () => $this->client->getPackageMetadata($name),
);
}
Keep the two data sources distinct in your API. getPackageMetadata() hits the Composer repository endpoint (fast, all versions, ideal for version lists and license/require resolution). getPackage() hits the main API (slower, one "current" record, richer in some fields). Model them as two methods on your wrapper — versions($name) and details($name) — so callers can't accidentally treat one as the other.
Security-advisory sweep for CI or a scheduled command. This is the highest-value day-to-day use. Feed composer.lock in and fail the build on hits:
$locked = collect($lock['packages'])
->mapWithKeys(fn ($p) => [$p['name'] => ltrim($p['version'], 'v')]);
$advisories = $packagist->getAdvisoriesAffectingVersions($locked->all());
if ($advisories !== []) {
$this->error('Vulnerable packages: '.implode(', ', array_keys($advisories)));
return self::FAILURE;
}
Use the timestamp overload on getAdvisories($packages, $since) for incremental scans — re-scanning everything nightly is wasted traffic.
Package discovery UI. searchPackages($q, [...]), searchPackagesByName, searchPackagesByTags and searchPackagesByType all return the same paginated envelope. Wire them to a simple "browse packages" page with page/perPage forwarded straight through, and read total/next from the payload rather than guessing page counts.
Point the client somewhere else by decorating the URL generator. Because URL generation is its own class, you can subclass PackagistUrlGenerator to target a Private Packagist instance or a Satis mirror and still use the same client, caching and tests. This is the one extension point worth knowing about.
Testing. Don't hit the network. Use Guzzle's MockHandler + HandlerStack with canned JSON in unit tests, and mock your wrapper (not the Spatie client) in feature tests so fixtures live in one place.
getPackageMetadata() is not getPackage(). Since 2.1.1 the metadata call uses Composer v2 metadata, which returns a packages key holding an array of version objects (with version_normalized, dist, require, …). getPackage() returns a package key with a single "latest" record. Code written against 2.1.0 will break on 2.1.1+ — pin and read the changelog before bumping.GuzzleHttp\Exception\ClientException/RequestException in your wrapper, or configure 'http_errors' => false on the Guzzle client and check status codes yourself.getPackagesNames() is enormous. It returns the entire package-name catalogue. Never call it in a web request — use getPackagesNamesByVendor() / ByType() or search instead.true third argument to getPackageMetadata($vendor, $package, true) pulls ~dev metadata for every branch. Request it only when you specifically need branch versions, and cache it harder.('spatie/packagist-api') or ('spatie', 'packagist-api'). Passing a string with no / throws (behaviour added in 1.2.1); an empty vendor throws too. Validate/normalize at your boundary.$data['package']['downloads']['total'] ?? null in Blade templates.composer.lock that's 120 calls. Chunk the list, cache aggressively, and consider usleep() between batches to be a good API citizen — Packagist is a free, unauthenticated service.abandoned key before surfacing a dependency as "healthy". It's present in the metadata payload and is often the most actionable thing in your inventory report.Middleware::history() (or a logging handler) behind a config flag; nothing in this package logs the outgoing URL for you, and the URL is usually the fastest way to see why a lookup failed.How can I help you explore Laravel packages today?