communicating breaking changes correctly
A version number that just keeps climbing without a recognizable pattern is worthless to other developers, because nobody knows anymore whether an update can be applied safely. SemVer turns a number into a promise: MAJOR, MINOR and PATCH tell consumers of a Magento module precisely what changed, before they even glance at the code.
Table of Contents
- 1. Why SemVer matters for your own Magento modules
- 2. The three numbers: reading MAJOR, MINOR, PATCH correctly
- 3. module.xml setup_version and the composer.json version
- 4. When is a change a breaking change?
- 5. Bumping the version number: the release workflow
- 6. Version constraints in composer.json
- 7. Communicating breaking changes: changelog and deprecation
- 8. SemVer across several of your own modules
- 9. SemVer discipline compared to other approaches
- 10. Summary
- 11. FAQ
1. Why SemVer matters for your own Magento modules
SemVer, Semantic Versioning, is a simple promise built from three numbers: MAJOR.MINOR.PATCH. For a custom Magento 2 module used across multiple client projects, this module versioning is not an academic detail, it is the only reliable communication channel between the module author and the teams that integrate it. Without SemVer, the only option left is checking every single update manually, which quickly becomes impractical across multiple projects and multiple modules.
In practice, the value shows up especially at agencies maintaining the same module across ten or more shops. A Composer update that accidentally ships a breaking change as a PATCH release can break several live shops at once on a Friday afternoon. Consistent SemVer versioning drastically reduces this risk, because consumers can decide through version constraints how much risk they are willing to accept with an update.
This matters even more the more automated your own deployment process is. When Composer updates are applied through CI pipelines without a manual checkpoint, reliable SemVer versioning is no longer a convenience, it is a hard prerequisite for running that automation safely at all.
The distinction from Magento's own core module versioning matters here: Adobe follows its own logic for core modules, tied to Magento releases. For custom modules released independently of Magento's main versions, pure SemVer following the official rules from semver.org applies, regardless of how Magento versions itself internally.
This difference is especially relevant when a custom module depends on a specific Magento version. Your own module versioning and Magento compatibility are two separate axes: a new MINOR version of your own module can add extra Magento compatibility without anything changing in the module's own public interface.
An often overlooked benefit is the role SemVer plays in onboarding speed for new team members. Anyone who encounters an unfamiliar module version in a project can immediately judge from the version number alone how risky an update would be, without first reading the entire change history. This predictability is an underrated productivity gain in any team larger than one or two people.
2. The three numbers: reading MAJOR, MINOR, PATCH correctly
In SemVer, each of the three numbers has a fixed meaning that is not up for negotiation. PATCH (the third number) stands for backward compatible bug fixes without new functionality. MINOR (the second number) stands for new, backward compatible functionality, for example an additional optional method on a service contract. MAJOR (the first number) stands for incompatible changes that can break existing code, such as a changed method signature or a removed interface.
The most common mistake in module versioning is using MINOR releases for changes that should actually be MAJOR, because a developer underestimates the impact of a change. An additional required parameter on a public method looks small internally, but is a breaking change for every consumer calling that method, and belongs firmly in a MAJOR release.
The opposite mistake, an overly cautious MAJOR release for what is actually a harmless addition, happens less often but causes unnecessary friction: consumer teams tend to postpone MAJOR updates out of habit, even when the actual migration effort would be minimal. An honest assessment of the version level therefore saves time on both sides.
A helpful rule of thumb: when in doubt, go one version level higher rather than lower. An unnecessary MAJOR release only costs consumers a deliberate update decision. A breaking change wrongly declared as MINOR or PATCH can, in the worst case, cost a broken live shop, because the Composer update was applied automatically and unchecked.
A special case is version 0.x.y, which SemVer defines as the initial development phase, where even MINOR releases are allowed to behave incompatibly. For Magento modules used in production, this phase only makes sense during the very first internal trial. As soon as a module runs in a real client project, the first stable version 1.0.0 should be assigned so that the full SemVer guarantees apply from that point on.
Pre-release labels such as 2.4.0-beta.1 or 2.4.0-rc.2 are also part of the SemVer standard and work well for trying out new functionality in selected projects before the actual release. Composer treats such pre-release versions as unstable by default and only resolves them if a consumer explicitly allows it via minimum-stability, which reliably prevents unwanted auto-updates to unfinished versions.
3. module.xml setup_version and the composer.json version
Magento 2 has two independent version concepts that are frequently confused. The setup_version in module.xml controls exclusively whether Magento's setup mechanism re-runs schema and data patches when the version increases. It has nothing to do with SemVer as an external version promise and does not have to follow the same rules, even though it is sensible to keep it in sync with the Composer version.
The version in composer.json, on the other hand, is usually not even present as a static field in modern Composer packages, it is derived by Composer from the Git tag when the package is included via a VCS repository. That means the actual SemVer version number of a module practically lives in the Git tag, not in a text line inside composer.json.
If a static version field is maintained anyway, for example for packages delivered through a private Satis server, that field must be kept manually in sync with the Git tag at every release. A drift between the two values is a common but easily avoidable source of errors, one that a simple CI check before every release can reliably prevent.
<!-- app/code/Mironsoft/LoyaltyPoints/etc/module.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Mironsoft_LoyaltyPoints" setup_version="2.3.0">
<sequence>
<module name="Magento_Sales"/>
<module name="Magento_Customer"/>
</sequence>
</module>
</config>
In practice this means: every data or schema patch requires bumping setup_version so that bin/magento setup:upgrade detects and runs the patch. The Composer tag, on the other hand, is set independently whenever a release is ready. Keeping both values in sync is not a Magento requirement, but good practice, since it avoids confusion in the team when "version 2.3.0" looks different in two places.
A common beginner mistake is bumping setup_version for a pure code change without any schema or data patch, assuming it should match the new Composer version. This is harmless but unnecessary: setup_version only needs to rise when an actual new patch is added under Setup/Patch/Schema or Setup/Patch/Data that Magento should run on the next setup:upgrade.
4. When is a change a breaking change?
The hardest decision in module versioning is correctly classifying a change. A breaking change exists whenever existing consumer code, written against the old version, no longer compiles cleanly after the update or behaves differently at runtime than previously documented. This covers public classes, interfaces, their method signatures, constructor parameters on classes instantiated via dependency injection, and published events.
Not every change to a class is automatically breaking. Changing a method marked private affects no external consumer. Adding a new, optional method to an interface is, strictly speaking, still breaking, because every existing implementation of the interface suddenly becomes incomplete, since PHP does not allow default implementations for interface methods. These exact edge cases are what require a deliberate breaking change analysis before every release, instead of relying on gut feeling.
Behavioral changes without a signature change also count in the module versioning analysis. If a method used to return null and now returns an empty collection, existing code still compiles, but may behave differently, for example if a consumer explicitly checks for null instead of emptiness. Such semantic breaking changes are harder to spot than pure signature changes and deserve special attention during review.
<?php
declare(strict_types=1);
namespace Mironsoft\LoyaltyPoints\Api;
/**
* Version 2.x - non-breaking: adding an optional parameter with a default value
* keeps existing callers compiling and behaving the same way.
*/
interface PointsCalculatorInterface
{
public function calculate(float $orderTotal, int $customerId, bool $includeBonus = false): int;
}
/**
* Version 3.0 - BREAKING: removing a parameter or changing its type
* forces every implementation and every caller to be updated. This
* requires a MAJOR version bump, never MINOR or PATCH.
*/
interface PointsCalculatorInterfaceV3
{
public function calculate(float $orderTotal, string $customerId): int;
}
5. Bumping the version number: the release workflow
A reproducible release process is the foundation of any reliable SemVer practice. The flow begins with a deliberate decision about the version level, followed by updating the changelog, setting the Git tag, and pushing to the private repository that Composer resolves the package from. Without this order, releases end up where the code already sits on the main branch but no tag exists, leading to inconsistent Composer resolution.
It is especially important to set the tag only once the code is actually release ready, including an updated changelog. A tag moved after the fact destroys trust in module versioning, because Composer caches the content of an already resolved version, and a moved tag can cause contradictory behavior between different projects that installed at different points in time.
An additional safety step is an automated CI job that checks, on every tag push, whether the changelog actually contains an entry for the new version, blocking the release otherwise. This small piece of automation reliably prevents the common human mistake of setting a tag and forgetting the changelog update in the rush of a release.
#!/usr/bin/env bash
set -euo pipefail
# 1. Update CHANGELOG.md with the new version block before tagging
# 2. Commit the changelog and any final release adjustments
git add CHANGELOG.md
git commit -m "Release v2.3.0"
# 3. Create an annotated tag matching SemVer exactly (leading "v" is a common convention)
git tag -a v2.3.0 -m "v2.3.0: add optional bonus points calculation"
# 4. Push commit and tag together
git push origin main
git push origin v2.3.0
# 5. Verify the tag is resolvable by Composer against the private repository
bin/cli composer show mironsoft/loyalty-points --all | grep "2.3.0"
6. Version constraints in composer.json
Consumers of a module control their update risk through version constraints in their own composer.json. The constraint ^2.3 allows all versions from 2.3.0 up to but excluding 3.0.0, following the SemVer promise exactly: MINOR and PATCH updates are accepted automatically, MAJOR updates are not. The constraint ~2.3.0 is narrower and only allows PATCH updates within 2.3.x. An exact version like 2.3.0 without an operator allows no automatic updates at all.
Choosing the right constraint depends on how much trust exists in the module versioning of the respective provider. For well maintained, disciplined modules, ^2.3 is the right choice, since it lets security updates through automatically without risking breaking changes. For modules with an unclear versioning history, the narrower ~2.3.0 binding, or even an exact version, is the safer choice until trust in release discipline has been established.
Regular composer outdated runs make visible which constraints allow which updates in practice, before a security update is even available. Combined with automated Dependabot or Renovate pull requests, this check can even be fully automated, so MINOR and PATCH updates are proposed regularly without manual effort, while MAJOR updates remain visible as a deliberate planning item.
For modules distributed through a private Composer server, a central dashboard showing which module version is currently in use across all consumer projects pays off. It makes visible at a glance which projects are running outdated versions with possible security gaps and should be actively nudged toward an update.
{
"require": {
"mironsoft/loyalty-points": "^2.3",
"mironsoft/gift-cards": "~1.4.0",
"mironsoft/legacy-import": "1.0.2"
},
"repositories": [
{ "type": "composer", "url": "https://satis.mironsoft.de" }
]
}
7. Communicating breaking changes: changelog and deprecation
A correct version number alone is not enough if nobody can look up what actually changed. A maintained CHANGELOG.md following the Keep a Changelog format documents the categories Added, Changed, Deprecated, Removed and Fixed for every version. For module versioning, the Breaking category matters most, often as its own highlighted section directly under the new MAJOR version, with concrete migration steps for consumers.
Before a removing MAJOR change, a deprecation phase in the previous MINOR version is the fairer practice. A method gets marked with @deprecated, stays functional, and is only actually removed in the next MAJOR release. This gives consumers time to adapt their code before an update is forced, and is the decisive difference between a respectful and a surprising breaking change.
Good practice is also to reference concrete lines in the migration guide from the changelog, instead of just writing "breaking changes, see code". A module provider who wants to build trust invests as much in this communication as in the actual code, because SemVer without understandable accompanying information only provides half the safety.
An often underrated tool is an automated upgrade script released alongside a MAJOR version, taking care of simple migration steps automatically, such as renaming configuration keys. For consumers running ten or more shops with the same module, such a script significantly reduces migration effort and makes even larger breaking changes practically manageable.
8. SemVer across several of your own modules
As soon as several of your own modules depend on each other, for example a loyalty module built on top of a shared core module, SemVer becomes the foundation for stable dependency chains. Every dependent module declares a constraint in its composer.json against the required version of the core module, and a MAJOR release of the core module must be deliberately propagated through the entire module chain instead of silently breaking it.
A common problem in multi-module projects is constraint divergence: module A requires ^2.0 of the core module, module B already requires ^3.0 because it uses a newer feature. Composer cannot resolve this conflict, and the project stays blocked until module A is also updated to version 3. Consistent module versioning with clear, regularly updated constraints across all your own modules prevents such blockages from becoming visible only late in the project.
A central overview of which of your own modules requires which version of which dependency also helps, whether as a simple table in an internal wiki or an automatically generated report from all composer.json files across the module family. This overview makes visible, before a release is planned, which consumer modules would be affected by an upcoming MAJOR update of the core module, and prevents an upgrade from hitting an unexpected constraint conflict mid deployment.
Another aspect often underestimated in growing module families is release ordering. If the core module is bumped to a new MAJOR version before all dependent modules have been migrated, temporary incompatibilities are inevitable in projects that update automatically. A fixed release calendar that deliberately bundles core updates with the migration of dependent modules reduces this friction considerably and makes module versioning across the entire module family predictable.
For especially large module families, an automated dependency graph that visualizes, for every planned release, which modules would be directly or transitively affected by a version change is worth the investment. Such a tool makes it immediately clear even to new team members why certain releases must happen in a fixed order while others can be published independently.
9. SemVer discipline compared to other approaches
Not every project starts with clean SemVer versioning. Many grown agency codebases initially use freely running version numbers or date based schemes. The difference in practical safety for consumers is substantial, especially once a module is used in more than one project.
The comparison below shows why the extra process overhead of SemVer pays off in practice as soon as more than one team or more than one project depends on a module. The breaking change detection column in particular makes clear that the other approaches structurally cannot offer a reliable early warning, no matter how carefully individual releases were prepared in detail.
| Approach | Breaking change detection | Consumer safety | Effort |
|---|---|---|---|
| Disciplined SemVer | Explicit via version level | High, constraints control risk | Medium, needs review discipline |
| Freely running number | Not detectable | Low, every update is a gamble | Low, but deceptive |
| Date based | Not detectable | Low, no constraint protection possible | Low |
| Only setup_version, no tag | Only visible for setup patches | Low, Composer ignores setup_version | Low |
Switching from a freely running scheme to disciplined SemVer already pays off from the second project that includes a module. The one time effort of defining a clean first major version and propagating constraints across all consumer projects pays for itself at every following release, because updates can then happen automatically and risk aware instead of manually and uncertain.
Even a retroactive switch in the middle of a module's lifecycle is doable. The usual path is to declare the current, freely running version as the new major version 1.0.0, apply SemVer rules consistently from that point on, and make it transparent in the release announcement that binding versioning applies from this version onward. Consumers can then deliberately decide if and when to switch to the new scheme.
That announcement should always include a brief explanation of why the switch happens and what concrete benefit it brings for consumer teams. A purely technical formality without visible value tends to get ignored by busy teams, while a clearly communicated safety gain noticeably increases acceptance of the new process.
Mironsoft
Release management, SemVer discipline and stable Composer dependencies
Module updates without nasty surprises?
We introduce SemVer discipline into your own Magento 2 modules, from the first clean major version through the release workflow to an understandable changelog for all consumer projects.
Versioning audit
Reviewing and correcting existing modules for SemVer compliance
Release workflow
Setting up tagging, changelog and a clean deprecation process
Constraint consulting
Setting risk aware Composer constraints for consumer projects
10. Summary
Disciplined SemVer turns the version number of a Magento module from an arbitrary figure into a reliable promise. PATCH for bug fixes, MINOR for backward compatible additions, MAJOR for breaking changes: these three rules, applied consistently, give every consumer team the ability to decide for themselves, through Composer constraints, how much update risk is acceptable. The separation between the module.xml setup_version and the Composer version derived from the Git tag is a common stumbling block here, one that clear process discipline easily avoids.
The decisive lever is not the version number alone, it is the communication around it. A maintained changelog, a deprecation phase before removing changes, and a reproducible release workflow with Git tags turn module versioning into a tool that builds trust between module provider and consumer teams, instead of gambling it away with every update.
In the end, disciplined SemVer pays off not just for a single update but across a module's entire lifetime. Teams that stick with this practice consistently spend noticeably less time on unplanned hotfixes after failed updates and considerably more time on actually developing their modules further.
SemVer for Magento modules: the essentials at a glance
The three numbers
PATCH for bug fixes, MINOR for compatible additions, MAJOR for breaking changes.
Two version sources
module.xml setup_version controls setup patches, the Git tag controls the Composer version.
Release workflow
Update the changelog, set the tag, only then push and update in consumer projects.
Communication
Deprecation before removal, an understandable changelog instead of just a version number.