myopensoft / php-version-bumper
Bump your project's semantic version from Conventional Commits and generate a Keep-a-Changelog entry. Framework-free, zero dependencies.
Requires
- php: ^8.3
Requires (Dev)
- laravel/pint: ^1.14
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- vimeo/psalm: ^6.0
README
Bump your project's semantic version from Conventional Commits
and generate a Keep a Changelog entry, using the commits since your
last git tag. Framework-free, zero runtime dependencies — a single git binary on PATH is all it needs.
Installation
composer require --dev myopensoft/php-version-bumper
CLI usage
vendor/bin/version-bump [options]
| Option | Effect |
|---|---|
--dry-run | Preview the next version and changelog without writing |
--commit | Stage and commit the version + changelog changes |
--tag | Create a git tag for the new version |
--push | Push the branch and tag to the configured remote |
--help, -h | Show usage |
--version, -V | Show the version-bump version |
The bump level follows Conventional Commits: feat → minor, fix/perf → patch, and any commit
marked breaking (feat!: or a BREAKING CHANGE: footer) → major. The baseline is your most recent
git tag; if no commit since that tag warrants a release, the command prints a notice and exits 0.
Committing, tagging, and pushing are each opt-in — a bare vendor/bin/version-bump only writes the
version file and the changelog.
Worked example
Given a repo tagged 1.0.0 with these commits since the tag:
feat(api): add export endpoint
fix(cli): correct exit code on failure
$ vendor/bin/version-bump --dry-run
Bumping 1.0.0 → 1.1.0 (Minor).
## [1.1.0] — 2026-08-02
### Added
- api: add export endpoint
### Fixed
- cli: correct exit code on failure
Dry run — nothing written, committed, or pushed.
Dropping --dry-run writes VERSION and prepends the entry to CHANGELOG.md. Adding --commit
--tag then creates the release commit and tag:
$ vendor/bin/version-bump --commit --tag
Bumping 1.0.0 → 1.1.0 (Minor).
## [1.1.0] — 2026-08-02
...
Released 1.1.0.
$ git log --oneline -1 && git tag --list '1.1.0'
4c7bc98 chore(release): 1.1.0
1.1.0
Configuration: version-bumper.php
Drop a version-bumper.php in your project root and the CLI will pick it up automatically; it is
entirely optional — with no file present, the defaults below already give you a working setup
(a VERSION file plus a CHANGELOG.md, both in the current working directory).
<?php
return [
// Where the current version lives: 'file', 'config_file', or 'git_tag'.
'source' => 'file',
// Path to the plain-text version file, used when source is 'file'.
'version_file' => 'VERSION',
'config_file' => [
// Path to the file to regex-patch, used when source is 'config_file'.
'path' => '',
// A regex with exactly one capture group around the version literal.
'pattern' => '',
],
// Path to the Keep-a-Changelog file that new entries are prepended to.
'changelog' => 'CHANGELOG.md',
// Format passed to DateTimeImmutable::format() for the changelog entry date.
'date_format' => 'Y-m-d',
// How many segments the version has, and which bump level moves each one.
// Tokens: major | minor | patch | manual. A `manual` segment to the left
// of the bumped one is yours — the tool copies it through untouched. A
// `manual` segment to the right of the bumped one is zeroed like any
// other. Each level token may appear at most once, and at least one must
// be present.
'scheme' => 'major.minor.patch',
// Commit type => changelog section heading. Types left out of this map
// are parsed but never appear in the changelog (e.g. docs, chore, test).
'sections' => [
'feat' => 'Added',
'fix' => 'Fixed',
'perf' => 'Changed',
],
// Commit type => bump level ('major', 'minor', or 'patch'). Types left
// out of this map never trigger a release on their own. A commit marked
// breaking (`feat!:` or a `BREAKING CHANGE:` footer) always bumps major,
// regardless of this map.
'bumps' => [
'feat' => 'minor',
'fix' => 'patch',
'perf' => 'patch',
],
'git' => [
// Remote pushed to when --push is passed.
'remote' => 'origin',
// Branch pushed to when --push is passed. null (the default)
// resolves to the current branch at run time.
'branch' => null,
],
];
Paths are resolved relative to the current working directory unless they are absolute.
Version scheme
scheme describes the shape of your version. It reads like the version it
produces, so the segment count is implied by the dots:
scheme | version | fix: | feat: | feat!: |
|---|---|---|---|---|
major.minor.patch (default) | 1.2.3 | 1.2.4 | 1.3.0 | 2.0.0 |
manual.manual.minor.patch | 1.2.3.4 | 1.2.3.5 | 1.2.4.0 | 1.2.4.0 |
major.minor | 1.2 | 1.3 | 1.3 | 2.0 |
Segments to the left of the bumped one are copied through unchanged; the bumped
segment and everything to its right is rewritten. A manual segment is only
left alone when it sits to the left of the bumped one — a manual segment to
the right is zeroed like any other, so it is not a place to keep a
hand-maintained build number.
When a bump level has no segment of its own, it falls back to the nearest less
significant mapped level, and only failing that to the nearest more significant
one. That is why row 2 turns a breaking change into a minor bump — there is no
major segment — and why row 3 turns a fix: into a minor bump, since there is
nothing below minor. A breaking change never blocks a release; the run warns and
the break still shows up in the changelog.
If your baseline tag has a different number of segments than the scheme, it is zero-padded or truncated to fit and the run warns once, so switching schemes is a config change rather than a manual re-tagging exercise:
$ vendor/bin/version-bump --dry-run
Baseline 1.2.3 read as 1.2.3.0 to match the 4-segment scheme.
Bumping 1.2.3.0 → 1.2.4.0 (Minor).
An invalid template — an unknown token, an empty segment, a repeated level, or
an all-manual scheme — throws an InvalidArgumentException naming the template.
Version sources
file(default) — reads and writes a plain-text version string inversion_file. A missing or empty file reads as0.0.0.config_file— regex-patches a version literal inside an arbitrary file, e.g. a'version'entry in some other config file. Bothconfig_file.pathandconfig_file.patternare required: selecting this source while either is left at its empty-string default throws anInvalidArgumentExceptionbefore anything is read or written.git_tag— reads the current version from the latest git tag;write()is a no-op, since the tag created by--tagis itself the new source of truth.
Library usage
Everything the CLI does is reachable directly, if you'd rather drive a release from your own script:
use Myopensoft\VersionBumper\Config;
use Myopensoft\VersionBumper\ReleaseFactory;
use Myopensoft\VersionBumper\ReleaseOptions;
$release = (new ReleaseFactory(Config::fromArray([]), (string) getcwd()))
->releaseRunner()
->run(new ReleaseOptions(dryRun: true));
echo $release->to; // '1.1.0'
echo $release->changelogEntry; // '## [1.1.0] — 2026-08-02 …'
Config::fromArray([]) applies the same built-in defaults as an absent version-bumper.php.
Release also carries from, bump (the effective level actually applied — under a custom
scheme this can differ from the level the commits implied, e.g. a breaking change demoted to
Minor when there is no major segment), status, and the buffered messages the CLI prints.
Laravel
myopensoft/laravel-version-bumper wraps
this package as an Artisan command (php artisan version:bump) with a published config file. If
you're on Laravel, install that package instead of wiring this one up by hand.
Testing & static analysis
composer test # Pest
composer analyse # PHPStan level 10
composer psalm # Psalm errorLevel 1
composer format # Pint
License
The MIT License (MIT). Please see the License File for more information.