sebastian/version
Library for managing the version numbers of Git-hosted PHP projects. Pass a release version and source path, then call asString() to get values like 1.0.0-17-g00f3408 from git describe, or plain X.Y.Z and X.Y-dev outside Git repositories.
Install as a dev dependency — version reporting is build/tooling concern, not request-path logic:
composer require --dev sebastian/version
Confirm the version line you actually got before writing code against it:
composer show sebastian/version
The 7.x line drops older PHP runtimes (7.0.0 removed PHP 8.3 support), so on an 8.2/8.3 Laravel app you will typically resolve to 5.x/6.x, or need --ignore-platform-req gymnastics. Pin explicitly (^6.0 / ^5.0) rather than floating, and re-verify the manifest constraint yourself — the package metadata around 7.x has been reported as inconsistent.
Where to look first: the README's four-bullet "How asString() works" list. That list is the specification; the class has one constructor and one method, so there is nothing else to learn.
First use case — a throwaway CLI probe. Before wiring anything into Laravel, prove what the string looks like in your repo:
<?php declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use SebastianBergmann\Version;
// $release: use X.Y.Z if a release exists, X.Y while on a dev series
echo (new Version('2.4', dirname(__DIR__)))->asString() . PHP_EOL;
Run php bin/version, then run git describe --tags in the same directory and compare. If those two disagree, stop — the problem is your tags or clone depth, not the PHP.
The one design decision that matters: the format of $release.
$release |
Not a Git repo | Git repo |
|---|---|---|
1.2.3 |
1.2.3 as-is |
git describe --tags verbatim |
1.2 |
1.2-dev |
1.2 + git describe --tags suffix |
For a Laravel app on a main branch with no per-commit tags, pass X.Y — you get 2.4-17-g00f3408 and can trace a running build to a commit. Pass X.Y.Z only if you tag every release commit and are happy with raw git describe output.
Config-driven release, not a hardcoded literal. Keep $release out of the class instantiation so CI can own it:
// config/version.php
return [
'release' => env('APP_RELEASE', '2.4'), // '2.4' on main, '2.4.1' at tag time
];
Artisan command — the natural Laravel home for this:
class VersionCommand extends Command
{
protected $signature = 'app:version';
public function handle(): int
{
$this->line($this->version()->asString());
return self::SUCCESS;
}
private function version(): Version
{
return new Version(config('version.release'), base_path());
}
}
base_path(), never __DIR__. The README's __DIR__ example assumes you are inside the project. In Laravel the call site is app/Console/Commands, so base_path() is the correct path — anything that resolves under vendor/ will describe the vendor package's repo (or none at all).
Register as a singleton and cache the result. Construction shells out to git; don't do it per request:
$this->app->singleton(Version::class, fn () => new Version(
config('version.release'),
base_path(),
));
Stamp the build, read at runtime. Best pattern for a web app: resolve the version once during deploy and write it to a file, so production images don't need Git installed:
// deploy step
file_put_contents(
base_path('bootstrap/cache/version.php'),
'<?php return ' . var_export((new Version($release, base_path()))->asString(), true) . ';'
);
Then serve it from a small helper reading that file. This decouples runtime from .git entirely — which is what you want, because most production images are built from an export, not a clone.
Internal health/version endpoint. Useful for confirming which build is behind a load balancer. Authenticate it, and consider trimming the -g00f3408 suffix for public surfaces.
CI integration. In GitHub Actions or GitLab CI, derive $release from the tag when present and fall back to the series:
$release = getenv('CI_COMMIT_TAG') ?: getenv('APP_RELEASE') ?: '2.4';
Tests. Assert the shape, not the literal string — the commit hash changes on every run:
$this->assertMatchesRegularExpression('/^2\.4(-\d+-g[0-9a-f]+|-dev)?$/', $version->asString());
Multi-series workflows (the 7.0.1 scenario). If you maintain main plus 6.x/7.x maintenance branches and merge them forward, make version resolution a release-gate check: merge an older-series tag into a branch and assert the reported version still starts with the current major. That is the exact regression 7.0.1 fixed, and it is cheap to test.
Silent fallbacks are the main hazard. There is no exception path — a missing .git, a shallow clone, or no tags at all just yields $release or $release-dev. You get a plausible-looking wrong answer. Log a warning when the version in a production environment ends with -dev, and treat that as a broken deploy rather than a cosmetic issue.
Shallow clones break git describe. Default CI checkouts (fetch-depth: 1) have no tags. Use fetch-depth: 0, or git fetch --depth=10 --tags, before generating. Otherwise your "version" is just your fallback string, and you won't notice.
Docker images without Git. The library shells out to the git binary. php:8.x-fpm-alpine doesn't ship it. Either install git in the build stage or (better) use the build-time stamping pattern above so runtime never calls asString().
Tag selection across release series. Before 7.0.1, merging tags from an older series into a branch after its most recent release could produce a version derived from the stale tag. If you're stuck on an older line and do maintenance merges, don't trust the output — verify against git describe --tags and add the regression test described above.
v-prefixed tags. If you tag v2.4.0, git describe returns v2.4.0-... and the leading v will leak into your version string. Either tag without the prefix or strip it in a thin wrapper.
Detached HEAD / untagged commits. git describe needs a reachable tag. On a feature branch cut from an untagged commit you'll get a long, ugly describe string — decide up front whether that's acceptable in your /version output.
Don't subclass for cosmetic reasons alone. Wrapping the class to return only the semver portion is fine, but remember the moment you strip the -g<sha> suffix you lose build traceability — which was the reason to adopt the package. Prefer stripping at the presentation layer (health endpoint) and keeping the full string in logs.
Extending when you need more. The class is final-ish in spirit (one constructor, one method); if you need per-package or monorepo versioning, or changelog generation, this is the wrong tool. For runtime "what's installed" questions, Composer's own InstalledVersions already has the data with no Git dependency.
Debugging checklist, in order: run git describe --tags manually in base_path(); check git tag --points-at HEAD; check git rev-parse --is-shallow-repository; then check config('version.release') and whether CI actually populated it. Nine times out of ten the PHP is fine and the clone isn't.
How can I help you explore Laravel packages today?