The Backward Compatibility Policy
The @api annotation marks the code Magento promises not to break; everything else can change in a patch release.
/** @api */ above the interface, class, method or constant you promise not to breakMagento's backward compatibility policy says which code you are allowed to build on. Code marked with the @api annotation in its doc block is public: Adobe promises not to break it within a major version. Everything else is private, and can change in a patch release with no notice. The annotation is the whole contract. There is no separate list.
What @api covers
Not only PHP classes and interfaces. Public code also includes JavaScript marked @api, virtual types marked @api, URL paths, console commands and their arguments, LESS variables and mixins, message queue topics and their data types, UI component declarations, layout handles a module declares, events a component dispatches, and the schema of configuration a module introduces. Magento\Catalog\Api\ProductRepositoryInterface carries @api. Its implementation, Magento\Catalog\Model\ProductRepository, does not. Depend on the first.
The version rules
Each module carries its own MAJOR.MINOR.PATCH version, separate from the 2.4.9 release number; composer show magento/module-catalog prints it. On an @api interface, removing a method's last argument is a MINOR change; adding an argument to a method, optional or required, is MAJOR, as is removing a method or changing a signature in any other way. Adding a method to a published interface is prohibited outright: publish a second interface alongside it. On a class the rules are looser — a new optional method argument is MINOR, and a new optional constructor argument appended last is PATCH, or MINOR if the class is meant to be extended.
What trips people up
Removing an @api tag is itself a breaking change. Deprecate the element instead and keep the tag in place.
Example
<?php
// app/code/Vendor/Loyalty/Api/PointsBalanceRepositoryInterface.php
declare(strict_types=1);
namespace Vendor\Loyalty\Api;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
use Vendor\Loyalty\Api\Data\PointsBalanceInterface;
use Vendor\Loyalty\Api\Data\PointsBalanceSearchResultsInterface;
/**
* Read and write loyalty point balances.
*
* Marked @api: once this module is released, no method may be added to this
* interface and no signature here may change until a MAJOR version.
*
* @api
* @since 1.0.0
*/
interface PointsBalanceRepositoryInterface
{
/**
* @throws NoSuchEntityException
*/
public function getByCustomerId(int $customerId): PointsBalanceInterface;
/**
* @throws CouldNotSaveException
*/
public function save(PointsBalanceInterface $balance): PointsBalanceInterface;
public function getList(
SearchCriteriaInterface $searchCriteria
): PointsBalanceSearchResultsInterface;
}
// Published through etc/di.xml, which binds the interface to the
// implementation. Changing the implementation is a PATCH change;
// changing this file is not. Apply either with:
// bin/magento setup:di:compileWhen to use it
- Decide whether your module's next release number is 1.1.0 or 1.0.1.
- Add a dependency to a class that other modules already extend.
- Judge whether a core class you are about to inject is safe to depend on at all.
- Answer a Marketplace review that flags a backward compatibility break in your module.
More examples
Adding a capability without touching the published interface
This is the pattern the policy requires and the reason Magento's own API namespaces are full of narrow interfaces. Reach for it whenever a service grows a new operation after release. The cost is one more file; the alternative is a MAJOR version and an upgrade note for everyone who implemented your interface.
<?php
// app/code/Vendor/Loyalty/Api/PointsBalanceRefundInterface.php
declare(strict_types=1);
namespace Vendor\Loyalty\Api;
use Magento\Framework\Exception\LocalizedException;
/**
* Refunding points is new in 1.1.0. It is a second interface, not a new
* method on PointsBalanceRepositoryInterface, because every integrator who
* implemented that interface would break the moment a method appeared on it.
*
* @api
* @since 1.1.0
*/
interface PointsBalanceRefundInterface
{
/**
* @throws LocalizedException
*/
public function refund(int $customerId, int $points, string $reason): void;
}
// One class may implement both interfaces. Consumers that only know the
// old one keep working; new consumers inject the new one. This release
// is 1.1.0 (MINOR), because public code was added.A new dependency in a class others extend
Appending an optional, nullable argument is the only way to add a dependency to a released class without a MAJOR bump. Because this class is marked @api and may be extended, the policy treats the addition as MINOR. Note the event name is public code too: once dispatched in a release, renaming it is a breaking change even though no PHP signature moved.
<?php
// app/code/Vendor/Loyalty/Model/PointsAccrual.php
declare(strict_types=1);
namespace Vendor\Loyalty\Model;
use Magento\Framework\Event\ManagerInterface;
use Psr\Log\LoggerInterface;
/**
* @api
* @since 1.0.0
*/
class PointsAccrual
{
public function __construct(
private readonly ManagerInterface $eventManager,
// Added in 1.1.0. Appended last, optional, nullable: every existing
// subclass and every di.xml argument override keeps working.
private readonly ?LoggerInterface $logger = null
) {
}
public function accrue(int $customerId, int $points): void
{
$this->logger?->info(sprintf('Accruing %d points', $points));
$this->eventManager->dispatch(
'vendor_loyalty_points_accrued',
['customer_id' => $customerId, 'points' => $points]
);
}
}
// `?->` is the nullsafe operator. Longhand, same behaviour:
// if ($this->logger !== null) {
// $this->logger->info(sprintf('Accruing %d points', $points));
// }Depending on the interface, not the implementation
This is the check to apply to every type in a constructor: open the class, look for @api. Most Magento classes under a Model namespace do not have it, and most interfaces under an Api namespace do. The catch clause omits the variable, which PHP 8 allows when the exception object is unused; the longhand is catch (NoSuchEntityException $e) and behaves the same.
<?php
// app/code/Vendor/Loyalty/Model/RewardLookup.php
declare(strict_types=1);
namespace Vendor\Loyalty\Model;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Exception\NoSuchEntityException;
class RewardLookup
{
public function __construct(
// ProductRepositoryInterface carries @api.
// Magento\Catalog\Model\ProductRepository does not: injecting the
// concrete class means a patch release may change it under you.
private readonly ProductRepositoryInterface $productRepository
) {
}
public function findBySku(string $sku): ?ProductInterface
{
try {
// get($sku, $editMode = false, $storeId = null, $forceReload = false)
return $this->productRepository->get($sku);
} catch (NoSuchEntityException) {
return null;
}
}
}
Discussion