The Backward Compatibility Policy

The @api annotation marks the code Magento promises not to break; everything else can change in a patch release.

Syntax/** @api */ above the interface, class, method or constant you promise not to break

Magento'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

Example · php
<?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:compile

When 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.

Example · php
<?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.

Example · php
<?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.

Example · php
<?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

  • Be the first to comment on this lesson.