shivas/versioning-bundle
Symfony bundle that simplifies application versioning by resolving the current version from Git, Composer, files, or environment variables and exposing it to services and Twig templates for display, cache busting, and release tracking.
Set expectations before you type a single command: this is not a Laravel package. shivas/versioning-bundle is a Symfony bundle — it ships a Bundle subclass, a DependencyInjection extension, and config tree definitions wired into FrameworkBundle's kernel. Laravel imports a handful of Symfony components (Console, HttpFoundation, Process, Finder) but not the bundle/kernel/DI-extension machinery, so there is no service provider to auto-discover and composer require shivas/versioning-bundle gets you a package that boots nothing. Laravel will not see a Version service, a Twig extension is dead weight (Blade isn't Twig), and the console command never registers.
So the first step is reframing the task: read this package as a reference implementation, not a dependency. It is a small, well-scoped pattern — resolve a version string from git describe/tags, expose it as a singleton, surface it in templates and a CLI command. That pattern is worth ~100 lines of Laravel-native code and half a day of work, and you get it without a non-native dependency or a single-maintainer bus factor.
Where to look first in the repo, in this order:
app:version mirrors the output shape.Resources/config/services.* — the service definition and its arguments tell you what's configurable (fallback, format, git_dir).Your first use case, which is also the highest-value one: a /version (or /health) endpoint plus the footer of your admin layout. That answers "which build is actually running in production?" during an incident, and it's the reason to bother at all.
Minimal Laravel-native equivalent:
# config/version.php
return [
// Injected by CI at build time — see Gotchas.
'version' => env('APP_VERSION'),
'git_dir' => base_path(),
'fallback' => 'unknown',
];
// app/Support/Version.php
final class Version
{
private ?string $resolved = null;
public function __construct(private array $config) {}
public function get(): string
{
return $this->resolved ??= $this->config['version']
?: $this->describe();
}
private function describe(): string
{
$gitDir = $this->config['git_dir'];
exec(sprintf('git -C %s describe --tags --always --dirty 2>/dev/null',
escapeshellarg($gitDir)), $out, $code);
return $code === 0 && !empty($out[0])
? trim($out[0])
: $this->config['fallback'];
}
}
Bind it as a singleton in AppServiceProvider::register(). Done — you now have everything the bundle offers.
Singletons, not per-request resolution. The bundle resolves once and holds it; do the same. exec('git describe') on every request is a subprocess you don't need. Bind Version as a singleton (or scoped), memoize the result as above, and rely on the fact that a deploy = a new container = a new value.
Expose it three ways, matching the bundle's three surface areas:
@version directive or share via View::share('appVersion', ...) in a view composer. Use it in the footer of internal layouts only — it's a diagnostic, not marketing copy.php artisan app:version is genuinely useful in deploy scripts; have it exit non-zero if the version resolves to unknown so a bad image fails loudly instead of silently shipping.Version anywhere and put it into log context.Log and error context is the underrated one. Laravel 11+ has Context:
Context::add('app_version', app(Version::class)->get());
Do this in a middleware or in bootstrap/app.php so every log line and every exception report carries the build identifier. When you're triaging a Sentry issue, "which release?" answered automatically is worth more than the endpoint.
Build-time stamping beats runtime introspection. The correct architecture, and the one that survives containers: CI computes the version (from the Git tag), passes it as a Docker build-arg, which becomes an ENV APP_VERSION in the image, which config/version.php reads. Runtime Git detection becomes a development-only fallback — useful when you're working locally on a checkout, irrelevant in production. This eliminates the entire class of ".git missing in the container" bugs rather than mitigating them.
Version resolution should be one function with a precedence chain, and the chain should be explicit: env var → version file → git describe → fallback. Write the chain down in the config file's comments so the next developer doesn't guess. Return commit SHA and tag, not just one; the tag tells you the release, the SHA tells you whether the image was rebuilt from the same tag.
Alternative to writing it yourself: if the above feels like too much surface area, sebastian/version or a tiny published helper exists, and Laravel's own app()->version() gives you the framework version but not yours — don't confuse the two in your footer.
Do not try to wrap the bundle. You could theoretically instantiate the Version class from the Symfony bundle if it has no container dependencies, pulling in only that one file. That works until the class references Symfony\Component\DependencyInjection\ParameterBag and you're now maintaining a compatibility shim for a class you could have written in 30 lines. Skip it.
.git is absent in production containers. This is the classic silent failure: the version resolves to dev-main or unknown and nobody notices until they need it. The bundle has the same flaw; the mitigation is build-time stamping as above. Add a smoke test in staging that asserts the version endpoint does not return your fallback string.
Shallow clones break tag resolution. actions/checkout defaults to fetch-depth: 1, which fetches no tags, so git describe --tags degrades to a bare SHA or fails. If your CI computes the version from Git, set fetch-depth: 0. If your CI only reads ${{ github.ref_name }} or a tag event, you sidestep this entirely.
config:cache freezes your version — and that's fine. Calling env() outside config files returns null once config is cached, which is the #1 cause of "version is empty in production, works locally." Always read via config('version.version'), never env() at runtime. And because config is cached at build/deploy time, the version is baked into the artefact — which is exactly the semantics you want.
Dirty-tree suffixes leak into your version. git describe --dirty appends -dirty when there are uncommitted changes. Great locally, confusing in a tag. Strip it, or only allow it in APP_ENV=local.
Monorepo tags are ambiguous. If you tag the whole repo but deploy one service, git describe gives you a repo-wide version that doesn't identify the service build. Either tag per-service (api-v1.4.2) or rely purely on the commit SHA plus build timestamp.
Version drift erodes trust in the diagnostic. If the reported string comes from a different source than the deployed artefact — e.g. CI reads the tag but the image was built from a branch head — the number lies, and people stop trusting the endpoint. Single source: one CI step computes it, one build-arg carries it, one config key exposes it. Nothing else may generate a version.
Watch the Symfony 8 / PHP 8.4 constraint. Relevant only if you somehow end up in a hybrid Symfony service: 4.2.0 widens support to Symfony 8, which requires PHP 8.4+, and pulling it can drag transitive upgrades into a shared monorepo lockfile. For a Symfony service on 6/7, verify the constraint range still admits your version before bumping; staying on 4.1.x is reasonable until the rest of the graph moves.
Extension points, if you ever are on Symfony: the Version service is a small enough interface that decorating it (e.g. appending build metadata) is straightforward. In Laravel the equivalent is binding an interface and swapping the implementation — same idea, and a good reason to type-hint an interface rather than the concrete class from day one.
Bus factor. Single maintainer, infrequent releases, no SLA. Another argument for the 30-line native version: it's not on the critical path, but a dependency you can't patch in an emergency still costs you something.
How can I help you explore Laravel packages today?