Planning semantic versioning, breaking changes and deprecation paths correctly
A clear versioning strategy decides whether an internal Symfony bundle can be confidently updated through composer update or causes surprises with every update. This article shows how to consistently apply semantic versioning, how to detect breaking changes and how to design deprecation paths for smooth migrations.
Table of contents
- 1. Why versioning matters especially for internal bundles
- 2. Semantic versioning: major, minor, patch in a bundle context
- 3. What counts as a breaking change in a Symfony bundle
- 4. Version constraints in the bundle's composer.json
- 5. Deprecation paths instead of abrupt removal
- 6. A changelog developers actually read
- 7. Automated breaking change detection with tooling
- 8. A repeatable release process
- 9. Versioning styles compared
- 10. Summary
- 11. FAQ
1. Why versioning matters especially for internal bundles
With a public Composer package, the community usually forces a clean versioning strategy quickly through issues and pull requests. With an internal Symfony bundle this external pressure is missing, which leads many teams to treat version numbers carelessly at first, for example with constantly incrementing patch versions for everything, including real breaking changes. That comes back to bite them as soon as several projects with different update rhythms consume the same bundle.
A well thought out versioning strategy is therefore not bureaucratic overhead, it is the foundation that keeps composer update predictable in a consuming project. If a team knows that a new minor version will never break existing code, it can roll out updates automatically, without manually reviewing the entire diff every time. Without this reliability, projects out of caution stay stuck on outdated, potentially insecure versions.
For a Symfony bundle used by several teams with varying levels of context knowledge, a clear versioning strategy is also a form of documentation: the version number itself already communicates how risky an update is likely to be, before anyone even reads the changelog.
Another, often overlooked aspect: an unclear versioning strategy also makes production debugging considerably harder. When an error occurs and nobody can say for certain which bundle version was active at what time in which project, troubleshooting becomes unnecessarily expensive. Clear, consistently assigned version numbers are therefore also a tool for incident response, not just for update planning.
2. Semantic versioning: major, minor, patch in a bundle context
Semantic versioning defines three numbers in the format MAJOR.MINOR.PATCH with clear meanings: a patch release fixes bugs without changing the public API's behavior, a minor release adds new, backward compatible functionality, and a major release contains at least one backward incompatible change. For a Symfony bundle, the public API is deliberately defined more broadly than just PHP method signatures: it also includes the configuration tree, every service id marked public, event names and the structure of any shipped routes.
This broader notion of API matters because many breaking changes in Symfony bundles do not happen in the PHP code itself, but in configuration. If a configuration key in the configuration tree is renamed or a default value changed, that is a breaking change from a consuming project's point of view, even if not a single method call is affected. A good versioning strategy therefore treats configuration changes with the same care as code changes.
{
"name": "acme/audit-bundle",
"version": "3.2.0",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
}
}
3. What counts as a breaking change in a Symfony bundle
For a bundle's practical versioning strategy, an explicit list of what counts as a breaking change is helpful: removing or renaming a service marked public, changing the signature of a public API method, removing a configuration key without a deprecation phase, changing a default value that alters runtime behavior, and raising the minimum supported PHP or Symfony version.
Not every change that feels like a breaking change actually is one in the sense of SemVer. Internal services marked private may change in any minor version, because they are explicitly not part of the public API. This distinction only works, though, if services are consistently and correctly marked public or private, which in turn requires clean extension design, as delivered by a well designed bundle extension.
A practical rule of thumb for edge cases: as soon as a consuming project can only survive a change through its own code adjustment effort, it is a breaking change, regardless of whether the change technically happens in PHP code, in configuration, or in a database schema. This consumer centric view is more reliable than a purely technical definition, which too easily narrows down to implementation details.
4. Version constraints in the bundle's composer.json
An often underestimated aspect of a versioning strategy: the bundle's own dependencies in composer.json should not be too narrowly scoped. A requirement such as symfony/framework-bundle pinned to exactly one version forces every consuming project onto that exact Symfony version, which quickly leads to unresolvable version conflicts once several bundles coexist in the same project. A range such as ^6.4 || ^7.0 gives the Composer resolver enough room without endangering compatibility.
At the same time, the lower bound of the range should be chosen deliberately: it marks the oldest Symfony version the bundle is actually tested against. A range that is too generously open at the bottom, and never tested in the CI matrix, is not a real compatibility guarantee, it is merely an unsubstantiated claim that in the worst case only becomes a problem at the customer's site.
{
"require": {
"php": ">=8.2",
"symfony/framework-bundle": "^6.4 || ^7.0",
"symfony/dependency-injection": "^6.4 || ^7.0"
},
"conflict": {
"acme/legacy-audit-bundle": "*"
}
}
5. Deprecation paths instead of abrupt removal
Instead of abruptly removing a feature in a major release, a solid versioning strategy should always provide a deprecation path spanning at least one minor version. Symfony itself uses this pattern consistently: a method marked as deprecated keeps working, but triggers a deprecation notice through trigger_deprecation() from the symfony/deprecation-contracts library when called, which becomes visible in tests and in the Symfony Profiler.
This approach gives consuming teams time to adjust their code before the next major version actually removes the old functionality. It matters that the deprecation message concretely states what to use instead, not just that something is deprecated. A good message names the replacement method or the new configuration key directly inside the message itself.
// src/Service/AuditLogger.php
declare(strict_types=1);
namespace Acme\AuditBundle\Service;
final class AuditLogger
{
/**
* @deprecated since 3.2, use logEntry() instead, will be removed in 4.0
*/
public function log(string $message): void
{
trigger_deprecation(
'acme/audit-bundle',
'3.2',
'The "%s()" method is deprecated, use "logEntry()" instead.',
__METHOD__
);
$this->logEntry($message, []);
}
public function logEntry(string $message, array $context): void
{
// Actual implementation of the audit log entry
}
}
6. A changelog developers actually read
A CHANGELOG.md following the Keep a Changelog format is the central communication channel of a versioning strategy. Every version gets its own section with the categories Added, Changed, Deprecated, Removed, Fixed and Security. This structure lets a consuming team grasp within seconds whether an update is relevant to them, without combing through the entire commit history.
Especially important for a Symfony bundle is a dedicated Upgrade Notes section on every major release, describing step by step what a consuming project needs to adjust. Without this explicit guidance, teams rely on trial and error, which with complex bundle configuration quickly leads to frustrating failed attempts, even though the actual migration often only affects a few lines.
A changelog is only worthwhile if it is actually updated with every release, not reconstructed after the fact. A proven trick: every pull request containing a publicly visible change must already bring a changelog entry in the Unreleased section before it can be merged. That way the changelog grows continuously instead of being a tedious afterthought right before the release.
## [3.2.0] - 2026-07-15
### Added
- New logEntry() method with structured context array support
### Deprecated
- log() is deprecated, use logEntry() instead, will be removed in 4.0
### Fixed
- Retention cleanup command no longer skips the last day of a month
## [3.1.0] - 2026-05-02
### Changed
- Default table_name changed from "audit" to "audit_log" for new installs only
7. Automated breaking change detection with tooling
Manually assessing whether a change is a breaking change stays error prone, especially under time pressure before a release. Tools such as roave/backward-compatibility-check analyze two git references of a PHP project and automatically list every detected BC break, from removed methods to changed type declarations. Wired into a CI pipeline, this tool prevents an accidental breaking change from being published as a minor or patch version.
This automated check does not replace the deliberate decision on configuration changes, which the tool cannot analyze, but it meaningfully complements the purely code based check. A realistic workflow combines the tool with a manual checklist for configuration tree changes, so both categories of breaking changes are reliably caught before a release is tagged.
# Compare the current branch against the last tagged release for BC breaks
vendor/bin/roave-backward-compatibility-check \
--from=3.1.0 \
--to=main
# Exit code is non-zero when a breaking change is detected,
# use this in a CI job to block accidental major changes in a minor release
Anyone wiring this tool into a CI pipeline should make the check a mandatory step before every merge into the main branch, not only shortly before release tagging. That way an accidental breaking change surfaces already in the pull request, when a fix is still cheap, instead of after release, when consuming projects might already be affected.
8. A repeatable release process
A bundle release should not be a manual, error prone process, but a repeatable one: update the changelog, adjust the version in composer.json if explicitly maintained there, run the BC check, set a git tag and push the tag. When wired to Private Packagist or Satis, the tag automatically triggers synchronization, so consuming projects can pick up the new version immediately through composer update.
For teams with frequent releases, a release script or a CI job that automates these steps pays off, while enforcing that a release cannot be tagged at all without an updated changelog entry. This automation reduces the main source of error in manual versioning: forgetting the changelog, or choosing the wrong version number under time pressure.
#!/usr/bin/env bash
# release.sh — repeatable release workflow for an internal Symfony bundle
set -euo pipefail
VERSION="$1"
# Refuse to tag without an updated changelog entry for this version
grep -q "## \[$VERSION\]" CHANGELOG.md || {
echo "Add a changelog entry for $VERSION before releasing." >&2
exit 1
}
vendor/bin/roave-backward-compatibility-check --from="$(git describe --tags --abbrev=0)" --to=HEAD
git tag -a "$VERSION" -m "Release $VERSION"
git push origin "$VERSION"
9. Versioning styles compared
Not every versioning strategy fits every bundle. The table below compares three common approaches.
| Approach | Predictability | Effort | Suited for |
|---|---|---|---|
| Strict SemVer with deprecation | Very high | High, requires discipline | Bundles with multiple consuming teams |
| Calendar versioning (CalVer) | Medium | Low | Bundles with a single consumer |
| dev-main without real tags | Very low | None | Nothing, local development only |
For most internal Symfony bundles with more than one consuming project, strict semantic versioning with clean deprecation paths is the only approach that builds trust long term. dev-main references without real version numbers are fundamentally unsuited for production dependencies, because they give up all predictability. Calendar based versioning can be a reasonable alternative when a bundle is really only used by a single team in a single project and the formal rigor of SemVer brings no practical extra value. But as soon as a second team or a second project joins, switching to strict semantic versioning almost always pays off, because the cost of a wrong compatibility expectation quickly exceeds the initial savings in discipline.
Mironsoft
Symfony bundle governance, versioning and release processes
Does every bundle update cause nasty surprises?
We establish a clear versioning strategy for your internal Symfony bundles, with automated BC detection, clean deprecation paths and a repeatable release process.
Introducing SemVer
Clear rules for what counts as a breaking change in your bundle context
BC check automation
Tooling in the CI pipeline that prevents accidental breaking changes
Release automation
Changelog, tagging and synchronization as a repeatable process
10. Summary
A solid versioning strategy for internal Symfony bundles rests on consistent semantic versioning that treats not only PHP method signatures, but also the configuration tree, public services and event names as part of the public API. Breaking changes are announced through deprecation paths with trigger_deprecation() instead of abruptly removing functionality, and a CHANGELOG.md following the Keep a Changelog format lets consuming teams assess every version in seconds.
Automated breaking change detection with tools such as roave/backward-compatibility-check catches accidental BC breaks before release, while a repeatable release process minimizes human error around version numbers and changelogs. Anyone combining these building blocks turns composer update for an internal bundle into a routine task instead of a risky bet on unknown behavior.
Internal Symfony Bundle Versioning Strategy — At a glance
Broaden the API definition
Configuration tree, public services and event names count as public API, not just PHP signatures.
Deprecate instead of removing
trigger_deprecation() at least one minor version before actual removal.
Changelog discipline
Keep a Changelog format with a dedicated Upgrade Notes section on major releases.
Automation
BC check tooling in the CI pipeline prevents accidental breaking changes.