Internal Symfony Bundle Versioning Strategy: Applying SemVer Correctly
AI generated
SF
{ }
Symfony · Versioning strategy · SemVer · Composer
Internal Symfony Bundle Versioning Strategy
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.

18 min read SemVer · Changelog · Deprecation · BC checks Symfony 7.x · Composer 2.x

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.

11. FAQ: Internal Symfony Bundle Versioning Strategy

1What counts as a bundle's public API?
Not only PHP signatures, also configuration tree, public services, event names and routes.
2Is a changed default value a breaking change?
Yes, as soon as it alters runtime behavior for existing consumers.
3How to cleanly announce removals?
With trigger_deprecation() at least one minor version ahead, including a pointer to the replacement.
4Narrow or broad dependency versioning?
Broad enough for fewer conflicts, but limited to Symfony versions actually tested.
5Which changelog format?
Keep a Changelog with Added, Changed, Deprecated, Removed, Fixed, Security, plus Upgrade Notes on majors.
6Detect breaking changes automatically?
With roave/backward-compatibility-check as a mandatory check in the CI pipeline.
7Does tooling also catch config changes?
Usually not fully, that additionally needs a manual review checklist.
8Why no dev-main reference in production?
Code can change at any time, without a deliberate, traceable version decision.
9How long should deprecation last?
At least one full minor version before actual removal.
10What belongs in a release process?
Update changelog, run BC check, set and push a git tag, ideally automated.