othercode/laravel-version
A small Laravel package for managing and displaying your application's version information. Use it to keep a single version source, expose the current version in your app, and reference it from config, views, or health and about endpoints.
Minimal steps
composer require othercode/laravel-version — Laravel package auto-discovery should register the service provider; if not, add it manually to config/app.php.php artisan vendor:publish --tag=version-config (confirm the exact tag from the README — with a package this small, the README is the documentation).config/version.php. The whole point of the package is that this list is data, not code: ['env', 'file', 'git', 'config'] for production containers, ['git', 'file', 'config'] for local dev./health//up endpoint to return version + which source won.Where to look first (in this order)
config/version.php after publishing — this is the entire public API surface in practice.register() method — that's where the resolver is bound and where the adapter contract (the "port") lives. That contract is the only thing you should ever code against.VersionFileSource, EnvSource, GitTagSource, ConfigSource or similar) — read these before trusting them in a deploy pipeline. They're small by design; you can audit all four in ten minutes.First use case
Deployment verification. A version endpoint is the cheapest possible smoke test:
// routes/api.php
Route::get('/version', fn () => response()->json([
'version' => app(VersionResolver::class)->resolve(),
'source' => app(VersionResolver::class)->sourceName(), // confirm method name
]));
Then in CI, after kubectl rollout status / forge deploy, curl https://app/api/version and assert the tag matches what you just built. This catches the classic "deploy silently no-op'd because the image tag didn't change" failure, and it catches it in seconds instead of in a bug report three days later.
Bind once, resolve once, cache
The resolver should be a singleton and its result should be memoized. Do not let it re-read the filesystem or shell out to git per request:
// AppServiceProvider::register()
$this->app->singleton(AppVersion::class, function ($app) {
$resolver = $app->make(VersionResolver::class);
return new AppVersion(
$resolver->resolve(), // string
$resolver->sourceName(), // 'git' | 'env' | 'file' | 'config'
);
});
Wrap it in your own value object (AppVersion) rather than injecting the package's resolver throughout your codebase. This is the single highest-leverage thing you can do with a zero-star dependency: it makes the package a swappable detail instead of a load-bearing wall.
Source precedence is your deployment contract
Think of the sources as a chain of responsibility with different jobs in different environments:
| Environment | Precedence | Why |
|---|---|---|
| Local dev | git → config |
Always correct, zero setup, changes with every commit |
| CI build stage | env (APP_VERSION set by pipeline) |
The pipeline knows the release tag before the image exists |
| Production container | file (VERSION baked at build) → env |
No .git in the image; VERSION is immutable and greppable |
| Tests | config (hardcoded) |
Deterministic; assertions don't break when you tag a release |
Bake the version at build time so it's identical everywhere:
ARG APP_VERSION=0.0.0-dev
RUN echo "${APP_VERSION}" > /var/www/html/VERSION
Middleware for a version header on every response
Cheap and enormously useful when debugging from a browser devtools panel or a customer's HAR file:
class AddVersionHeader
{
public function __construct(private AppVersion $version) {}
public function handle($request, Closure $next)
{
$response = $next($request);
$response->headers->set('X-App-Version', $this->version->toString());
return $response;
}
}
Only do this on non-public responses if exposing your version is a concern (see Gotchas).
Asset cache busting
Laravel's mix()/vite() already handles manifest hashing, but if you serve assets from a CDN or have long-lived caches keyed by app version, feed the resolved version into config(['app.asset_version' => ...]) at boot, or append it as a query string. One version number to correlate "which release broke the CSS" with "which release broke the API."
Release tracking in error reporting
// Sentry / Bugsnag / Rollbar init
'release' => app(AppVersion::class)->toString(),
This is where a version resolver stops being decorative. Every exception report now carries the release, so you can say "this started in 2.4.1" instead of "around Tuesday."
Testing
Bind a fixed version in TestCase::setUp() or a dedicated trait:
$this->app->instance(AppVersion::class, new AppVersion('1.2.3-test', 'config'));
Then assert on the version endpoint without depending on the developer's git state or a VERSION file existing on the CI runner. Without this, your version tests will pass locally and fail in CI — a classic shallow-clone problem (see Gotchas).
Health check integration
Most teams end up with more than one health check: a liveness probe (cheap, no dependencies) and a readiness/deploy-verification one. Version belongs on the second one, alongside a migration count or queue depth — it's diagnostic, not a liveness signal. Don't put git invocation behind a liveness probe that's hit every two seconds.
The shallow-clone trap. git describe / git tag resolution fails in CI. GitHub Actions checks out with fetch-depth: 1 by default, so no tags exist and the git source silently returns nothing — or throws. Fix: fetch-depth: 0 in actions/checkout, or set APP_VERSION from github.ref_name, or just accept the fallback. But always verify what the resolver returns in CI, because a wrong-but-plausible version is worse than a missing one. If you can't get tags, the resolver should fall back to a 0.0.0+sha style value rather than an empty string.
.dockerignore deleting your VERSION file. You spend an hour wiring up the file source and the Docker build silently drops it. Grep your .dockerignore and then docker run --rm <image> cat VERSION as a build-time assertion.
config:cache and boot-time resolution. If you resolve at boot and the value depends on a runtime env var, php artisan config:cache can freeze a stale value into the cached config. Keep version resolution out of config/version.php's env() calls where the value must be dynamic — or better, accept that version is a build artifact and should be frozen. Be deliberate about which you want.
Sources disagreeing is the main failure mode. Four sources means four opinions. Always log or expose which source won — that single string (source: 'file') turns a two-hour debugging session into a two-second one. If two sources are configured and disagree, decide explicitly whether that's an error (fail loud) or a precedence rule (fail quiet). For a package this small, that behavior is whichever the adapter code says — read it.
It's a string, not a semver object. Despite the name, don't assume this parses or validates semver. It returns whatever the source contained, trailing newline and all — .trim() defensively. Don't build comparison logic (is version > x?) on top of it without a real semver library.
0 stars, 0 dependents, no ecosystem. This is the honest caveat: there is no community, no CVE feed, no Stack Overflow answers, and the bus factor is one. It's a tiny package — that's the mitigating factor — but treat it accordingly:
composer.json.AppVersion wrapper so removal is a one-file change.app/Support/ rather than taking the dependency. For a package this size, a local copy you fully understand often beats a remote one you don't.Extension points: write your own source. The pluggable-source design is the package's actual value proposition. Implement the contract and register it in config for anything non-standard — a /build-info.json written by your CI, a database-stored release record, a cloud provider's deployment metadata API (careful: that's a network call at boot, so cache it). The moment you need a fifth source, do it via the contract rather than forking.
Don't leak version detail on public surfaces. A precise version + commit SHA on an unauthenticated endpoint tells an attacker exactly which CVEs to try. Prefer "which release" on internal health endpoints, and if you must expose publicly, expose only a major.minor and only behind auth. The X-App-Version header on every response is a convenience — make sure that trade-off is a decision, not an accident.
Debugging checklist when the version looks wrong, in order: (1) does VERSION exist in the running container? (2) is APP_VERSION set in that environment? (3) does git describe --tags work as the app user in that directory? (4) is config:cache serving a stale config? (5) which source does the resolver report as the winner? Almost every bug is answered by (5), which is why exposing it is worth the two lines.
How can I help you explore Laravel packages today?