ocramius/package-versions
PackageVersions gives PHP applications fast, zero-I/O access to the installed versions of Composer dependencies. Version data is compiled from composer.lock at install time, so \PackageVersions\Versions::getVersion() returns a package's version and commit hash without any runtime disk reads—ideal fo...
composer require ocramius/package-versions
Then turn on the optimized autoloader (the package's own README recommendation) so the class lookup stays cheap:
{
"config": {
"optimize-autoloader": true
}
}
First call. Confirm it resolves in your app before designing anything around it:
php artisan tinker
>>> PackageVersions\Versions::getVersion('laravel/framework')
=> "11.9.2@0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33"
Two things to notice immediately, because they shape every pattern below:
version@reference, not a clean semver string. You either split it or use getShortVersion().Versions::getVersion() is a static call on a final class. It works but it is not injectable — wrap it before it leaks into controllers.Where to look first. The README's "Purpose" + "Use-cases" sections (it is a two-minute read), then PackageVersions\Versions itself: getVersion(), getShortVersion(), rootPackageName(). Nothing else exists — there is no config file, no service provider, no events in 2.x.
First real use case for a Laravel app: a /api/_version style endpoint or asset cache-buster that reports what is actually installed. The competing approaches — exec('composer show ...'), parsing composer.lock by hand, or a git describe shell-out — either require a Composer binary in production or add per-request I/O. This package is the boring, cheap version of that: resolve once, string out.
Check your Composer version. If the project is on Composer 2, Composer\InstalledVersions ships with Composer itself and covers most of the same ground (getPrettyVersion(), getReference(), isInstalled(), satisfies()). Decide deliberately whether you want the extra dependency; for a Laravel app already carrying 90 packages it is usually a style choice, not a weight problem — but know which one you are standardising on before two different call sites appear.
Versions is static and untestable by default. Give your app a seam:
namespace App\Support;
use PackageVersions\Versions;
final class PackageVersion
{
/** @var array<string, string> */
private array $cache = [];
public function version(string $package): ?string
{
if (isset($this->cache[$package])) {
return $this->cache[$package];
}
try {
return $this->cache[$package] = explode('@', Versions::getVersion($package))[0];
} catch (\Throwable) {
return null; // unknown package, or metadata not readable
}
}
public function app(): string
{
return (string) config('app.build_version', 'dev');
}
public function framework(): string
{
return $this->version('laravel/framework') ?? 'unknown';
}
}
Bind it as a singleton in AppServiceProvider::register() ($this->app->singleton(PackageVersion::class)). The singleton matters: the first resolution touches disk, and you do not want that per call site. Tests then bind a fake and never touch Composer metadata at all.
The classic use: a value that changes when the deployed code changes.
public function assetId(): string
{
return substr(sha1($this->framework().'|'.$this->app()), 0, 12);
}
Use it as a cache key suffix, an ETag, or an email template signature. Prefer generating it at deploy time (a composer script or CI step writing config/build.php) if you want it frozen at build rather than at runtime; prefer runtime resolution if you want dev and prod to diverge naturally.
Route::get('/_version', function (PackageVersion $v) {
return response()->json([
'app' => $v->app(),
'framework' => $v->framework(),
'php' => PHP_VERSION,
]);
})->middleware('throttle:60,1');
Gate it: ->middleware(['auth', 'can:viewOps']) or hide it behind app()->isProduction() checks. In practice, do not put this on the public internet — version disclosure is a real (if low) signal for attackers.
Two places where "what version is running" pays for itself immediately:
laravel/framework and your own build id in a before_send hook or in App\Exceptions\Handler. When a bug only reproduces on one deploy, you will be glad you did.User-Agent that identifies your integration; GitHub rejects requests without one.Http::withHeaders([
'User-Agent' => sprintf('MyApp/%s (Laravel %s)', $v->app(), $v->framework()),
])->get('https://api.github.com/user/repos');
Useful for packages you cannot control (internal SDKs, payment gateways):
if ($v->version('acme/payments-sdk') && version_compare($v->version('acme/payments-sdk'), '3.2.0', '>=')) {
// new payout endpoint only exists in 3.2+
}
Prefer composer/semver's Semver::satisfies($version, '^3.2') over version_compare when constraints get interesting, and prefer Composer\InstalledVersions::satisfies() if you have already standardised on Composer 2.
Add a line to php artisan about or a dedicated php artisan app:version command. Ops people run one command instead of docker exec ... composer show. This is the highest ratio-of-value-to-effort use case in the whole package.
Resolve once in a provider, store the result in a config array or a singleton, inject that. composer dump-autoload -o in your deploy pipeline keeps the autoload side free; the metadata read itself is a single cached pass.
The return format is version@reference. Everything downstream wants just the version. explode('@', $v)[0] or getShortVersion(). Remember dev dependencies can return dev-main-style values, so do not assume semver parsability — validate before feeding into version_compare.
It needs Composer metadata on disk at runtime. Since 2.0 this is a plain library, not a Composer plugin: it resolves from composer.lock (and composer.json for rootPackageName()) rather than a generated class file. Practical consequences:
composer config lock false projects are unsupported — there is no lock file to read.COPY --from=builder /app/vendor /app/vendor without composer.lock and composer.json will fail at the first call. Copy the whole app root, or accept the failure — do not discover it from a 500 in production.vendor-dir setups deserve a smoke test.composer install --no-dev is not the same as "not installed". The lock file still lists dev packages, so a version can be resolvable for a package that is physically absent in production. Never use "a version came back" as proof the class exists — guard with class_exists() or InstalledVersions::isInstalled() if you are feature-detecting.
Your own app has no version in the lock file. rootPackageName() gives you the name. For the app's version, inject a build id / git SHA / composer.json version field through config at deploy. Do not expect getVersion(rootPackageName()) to be meaningful.
Unknown packages: getVersion() throws for a package that is not in the lock. In a controller, that is a 500. Always try/catch at the seam (as in the wrapper above) and decide what null means for each call site.
Static in-process cache. The value is resolved once per process and reused — great for Octane and queue workers, but it also means the value never changes mid-process. That is correct behaviour for deployed code, but it means you cannot "test" a version change without restarting the worker. Deploy scripts must restart Octane/queue workers anyway; this just makes it mandatory.
Config caching bakes the value at config:cache time. If you ever call the package inside config/*.php, remember those files are evaluated once and serialised. If your build pipeline runs config:cache in an image before prod dependencies are installed, the cached value is a lie. Prefer resolving in a service provider or a computed binding.
Optimize the autoloader; do not skip scripts in ways that break your build. Older 1.x releases had documented friction with --classmap-authoritative and --no-scripts; 2.x is much simpler, but keep composer dump-autoload -o in the pipeline and keep composer.lock committed and fresh (composer update --lock if it drifts). A stale lock gives you confidently wrong answers.
Do not shell out instead. exec('composer show --format=json') in a request is exactly the anti-pattern this package exists to kill: process spawn, no Composer binary in the runtime image, filesystem I/O per call, and output that differs across Composer versions.
Extension points: there are none, and that is the point. No config, no events, no plugin hooks in 2.x. Your extension point is your own wrapper. Keep it the only place that imports PackageVersions\Versions — then swapping to Composer\InstalledVersions, or to a build-time generated config/versions.php, is a one-file change rather than a grep across the app.
How can I help you explore Laravel packages today?