Active Record Pattern in Magento 2
AI generated
mironsoft.de › Blog › Design Patterns
Magento 2 · Design Patterns
Active Record Pattern
in Magento 2

The Active Record Pattern combines database access and business logic in a single object. Magento 2 is historically built on it, yet modern Magento 2.4.8 clearly favors the Repository Pattern. Why that is, which bugs are typical, and how the migration succeeds.

15 min read PHP 8.4 Magento 2.4.8

The Active Record Pattern: origin and definition

The Active Record Pattern was described by Martin Fowler in his 2002 book "Patterns of Enterprise Application Architecture" and has shaped web development ever since. The core idea is simple and elegant at first glance: an object simultaneously represents a row in the database table and contains the logic to load, save and delete that data. The Active Record Pattern bundles database access and business logic in a single class. The object knows how to persist itself.

Frameworks like Ruby on Rails made the Active Record Pattern popular and shaped an entire generation of developers along the way. In Rails you write User.find(1), user.save or user.destroy, the object takes care of the database operations itself. The class and the database table are directly linked. This convention over configuration approach sped up many web projects because developers did not have to deal with database abstraction layers.

Magento 1, when it launched in 2008, adopted the Active Record Pattern and integrated it deeply into its architecture. The classes Mage_Core_Model_Abstract and the entire Model system were based on the idea that a Model knows and executes its own database operations. That decision had far-reaching consequences for Magento's entire codebase and was initially kept in Magento 2, before the platform gradually began shifting toward the Repository Pattern.

The classic Active Record Pattern looks roughly like this in PHP: a class contains both the data fields and the methods save(), find() and delete(). The object knows the table name and executes the SQL queries itself. For small applications this is convenient, but as complexity grows, problems arise that we will look at more closely throughout this article.

Active Record in Magento 2: Model and ResourceModel

Magento 2 did not adopt the Active Record Pattern directly, but developed its own variant that splits the Active Record Pattern into two separate classes. The Model holds the data and business logic, while the ResourceModel encapsulates the actual database access. The Model delegates all database operations to the ResourceModel, which is Magento 2's answer to the weaknesses of the pure Active Record Pattern.

A Magento 2 Model extends Magento\Framework\Model\AbstractModel and registers itself with its ResourceModel via the _construct() method. The call $this->_init(PostResource::class) links the two classes. The Model itself does not run any SQL queries, it delegates all persistence operations to the ResourceModel. Yet from the outside, its usage is identical to classic Active Record: you call $model->load($id) and $model->save() without worrying about the internal split.

<?php
declare(strict_types=1);

namespace Mironsoft\Blog\Model;

use Magento\Framework\Model\AbstractModel;
use Mironsoft\Blog\Model\ResourceModel\Post as PostResource;

/**
 * Blog post model, delegates persistence to ResourceModel.
 */
class Post extends AbstractModel
{
    protected function _construct(): void
    {
        // Link model to its ResourceModel (Active Record delegation)
        $this->_init(PostResource::class);
    }

    /**
     * Get post title.
     */
    public function getTitle(): string
    {
        return (string) $this->getData('title');
    }

    /**
     * Set post title.
     */
    public function setTitle(string $title): static
    {
        return $this->setData('title', $title);
    }

    /**
     * Get post status.
     */
    public function isActive(): bool
    {
        return (bool) $this->getData('is_active');
    }
}

The ResourceModel extends Magento\Framework\Model\ResourceModel\Db\AbstractDb and specifies the table name and primary key in the _construct() method. It holds the database connection and all the SQL logic. The ResourceModel can also have its own methods that perform more complex database operations, for example loading by a specific field like the URL key. In Magento 2.4.8 this structure remains in place, even though the public API is increasingly expected to run through repositories.

Using the Active Record Pattern in Magento 2 looks straightforward at first glance: you create a Model via the factory, load data with load(), modify it with setData() and save with save(). This simplicity was historically an advantage, developers could quickly write working code without getting familiar with more complex abstraction layers. But the price becomes visible as soon as the code grows or needs to be tested.

The deliberate separation: Model vs. ResourceModel

Splitting into Model and ResourceModel in Magento 2 is an important architectural decision that improves on the pure Active Record Pattern, but does not fully overcome it. The Model is responsible for holding data in structured form and providing business logic. The ResourceModel is solely responsible for database access: it knows the table name, builds SQL queries and processes the result.

This separation follows the principle of Separation of Concerns, but remains half-hearted because the Model still delegates persistence operations and therefore stays tightly coupled to its ResourceModel. In a clean architecture, the Model should know nothing about the database at all, that is the idea behind the Repository Pattern and the Data Mapper Pattern, which we will look at later in this article.

<?php
declare(strict_types=1);

namespace Mironsoft\Blog\Model\ResourceModel;

use Magento\Framework\Model\ResourceModel\Db\AbstractDb;

/**
 * Blog post resource model, handles all database operations.
 */
class Post extends AbstractDb
{
    protected function _construct(): void
    {
        // Table name and primary key column
        $this->_init('mironsoft_blog_post', 'post_id');
    }

    /**
     * Load post by URL key, custom resource method.
     */
    public function loadByUrlKey(
        \Mironsoft\Blog\Model\Post $post,
        string $urlKey
    ): static {
        $connection = $this->getConnection();
        $select = $connection->select()
            ->from($this->getMainTable())
            ->where('url_key = ?', $urlKey)
            ->limit(1);

        $data = $connection->fetchRow($select);
        if ($data) {
            $post->setData($data);
            $post->setOrigData();
        }
        return $this;
    }
}

The ResourceModel can contain its own methods like loadByUrlKey() that go beyond standard CRUD operations. These methods live directly on the ResourceModel rather than the Model, which is a genuine step toward better separation. Still, the fundamental problem remains: the caller still has to manipulate Model instances and call save(), which leads to the typical Active Record problems.

Another aspect of this separation is Model events. Magento 2 dispatches events on save and delete such as model_save_before, model_save_after, model_delete_before, as well as class-specific events. These events are triggered inside the ResourceModel and allow other modules to react to persistence operations. That is a powerful feature, but it also makes the codebase harder to follow, since the control flow runs through the event system.

Why Active Record is an anti-pattern

The Active Record Pattern fundamentally violates the Single Responsibility Principle (SRP). A class that contains business logic and also knows how to write itself to the database has at least two reasons to change: when business logic changes and when the database schema changes. In large e-commerce projects like Magento 2, this violation quickly becomes noticeable, because both aspects frequently change independently of each other.

The getData() and setData() methods of the Magento 2 Model are not type-safe. A call like $model->getData('price') returns mixed. IDE support and static analysis fail here. PHPStan or Psalm cannot perform reliable type checks as long as the AbstractModel's magic-method architecture is being used. In PHP 8.4 with strict types, that is especially unpleasant, because you constantly have to cast values and lose type safety along the way.

The Active Record Pattern makes unit tests difficult to impossible. A $model->load(42) call requires a database connection. To test that, you either need real database access (integration test) or you have to mock the ResourceModel, which is cumbersome due to the tight coupling. By comparison, a repository interface is trivial to mock: $repositoryMock->method('getById')->willReturn($fakePost). That is the decisive difference in testability.

Magento has officially acknowledged this problem and marked AbstractModel::load() as deprecated in Magento 2.4. The deprecation notice in the core code is unambiguous: developers should use repository interfaces. PHPStan level 5 and higher flag every load() call as a warning. Anyone running PHPStan in a CI/CD pipeline sees this warning immediately. In Magento 2.4.8, the Active Record Pattern is no longer acceptable for new code.

Typical bugs and performance problems

The most common bug in the Active Record Pattern in Magento 2 is the so-called "phantom save" problem. A developer loads a Model with an invalid ID, does not check whether the load succeeded, and then calls save(). The Model is empty and has no ID, and Magento performs an INSERT instead of an UPDATE, creating an empty row in the database. The Repository Pattern prevents this bug through NoSuchEntityException: if the entity does not exist, an exception is thrown immediately.

Another typical problem is the N+1 query bug. With a collection of blog posts, a separate load() call is made for each post to, for example, load its author. That leads to 101 database queries for 100 posts. With the Repository Pattern and a well-designed collection, this problem can be solved through eager loading or JOIN queries. The Active Record Pattern, on the other hand, tempts you into loading relations lazily and one by one, which becomes catastrophic with large datasets.

The Active Record Pattern in Magento 2 runs a SELECT * by default, loading every column even if you only need the title and the URL. For products with many EAV attributes, this is especially problematic, because dozens of JOIN operations get executed as a result. The Repository Pattern makes it possible to load only the fields you actually need, which considerably optimizes the database query.

A subtle bug arises when you manipulate data after a load() call and then unintentionally call save() on the modified Model. This happens especially in plugins or observers that further process an already loaded Model. The Repository Pattern clearly separates the read operation (getById()) from the write operation (save()) and makes it obvious when a persistence operation is taking place.

Data Mapper as a conceptual alternative

The Data Mapper Pattern is the conceptual alternative to the Active Record Pattern. While in Active Record the object itself manages persistence, in Data Mapper a separate class, the mapper, handles the translation between the domain object and the database. The domain object is completely unaware of database details. It only holds data and business logic, and knows nothing about SQL queries or table names.

The Repository Pattern in Magento 2 is a variant of the Data Mapper Pattern. The repository takes on the role of the mapper: it loads entities from the database, creates Model objects and returns them. It saves and deletes entities on explicit request. The domain class (the Model or the Data Interface) does not need to know how it is stored in the database.

In an ideal Magento 2.4.8 architecture, there is a clear layer separation: the domain layer contains Data Interfaces like PostInterface with typed getters and setters. The repository layer contains PostRepositoryInterface with CRUD operations. The infrastructure layer contains the ResourceModel and the Collection. These layers communicate only through defined interfaces, with no direct dependency between the domain and the database.

The practical difference becomes visible in testing: with the Data Mapper Pattern, domain objects can be created and populated without database access and used in business logic tests. Persistence is simulated through mock repositories. With the Active Record Pattern, that is not possible, because the object always wants to interact with the database.

The Repository Pattern in Magento 2

The Repository Pattern is the official way in Magento 2.4.8 to access entities. A repository is a class that encapsulates all database operations for an entity group and exposes them through a type-safe interface. The interface defines methods like getById(), save(), delete() and getList(). The concrete implementation stays hidden behind the interface.

Service Contracts is the umbrella term for the stable PHP interfaces through which modules communicate with each other. Repository interfaces are Service Contracts. Data Interfaces (such as PostInterface) are also Service Contracts. They define a stable API layer that does not change between major releases, at least in theory. In practice, this means that API calls through repository interfaces are more upgrade-safe than direct Model manipulation.

<?php
declare(strict_types=1);

namespace Mironsoft\Blog\Model;

use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Api\SearchResultsInterfaceFactory;
use Magento\Framework\Exception\CouldNotDeleteException;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\Blog\Api\Data\PostInterface;
use Mironsoft\Blog\Api\PostRepositoryInterface;
use Mironsoft\Blog\Model\ResourceModel\Post as PostResource;
use Mironsoft\Blog\Model\ResourceModel\Post\CollectionFactory;

/**
 * Blog post repository implementation, replaces Active Record pattern.
 */
class PostRepository implements PostRepositoryInterface
{
    public function __construct(
        private readonly PostFactory $postFactory,
        private readonly PostResource $postResource,
        private readonly CollectionFactory $collectionFactory,
        private readonly SearchResultsInterfaceFactory $searchResultsFactory
    ) {}

    /**
     * Load post by ID, throws exception if not found.
     *
     * @throws NoSuchEntityException
     */
    public function getById(int $id): PostInterface
    {
        $post = $this->postFactory->create();
        $this->postResource->load($post, $id);

        if (!(int) $post->getId()) {
            throw new NoSuchEntityException(
                __('Blog post with ID "%1" does not exist.', $id)
            );
        }

        return $post;
    }

    /**
     * Save a post entity.
     *
     * @throws CouldNotSaveException
     */
    public function save(PostInterface $post): PostInterface
    {
        try {
            $this->postResource->save($post);
        } catch (\Exception $e) {
            throw new CouldNotSaveException(
                __('Could not save blog post: %1', $e->getMessage()),
                $e
            );
        }

        return $post;
    }

    /**
     * Delete a post entity.
     *
     * @throws CouldNotDeleteException
     */
    public function delete(PostInterface $post): bool
    {
        try {
            $this->postResource->delete($post);
        } catch (\Exception $e) {
            throw new CouldNotDeleteException(
                __('Could not delete blog post: %1', $e->getMessage()),
                $e
            );
        }

        return true;
    }

    /**
     * Get list of posts using SearchCriteria.
     */
    public function getList(SearchCriteriaInterface $criteria): \Magento\Framework\Api\SearchResultsInterface
    {
        $collection = $this->collectionFactory->create();

        foreach ($criteria->getFilterGroups() as $filterGroup) {
            foreach ($filterGroup->getFilters() as $filter) {
                $collection->addFieldToFilter(
                    $filter->getField(),
                    [$filter->getConditionType() => $filter->getValue()]
                );
            }
        }

        $results = $this->searchResultsFactory->create();
        $results->setSearchCriteria($criteria);
        $results->setTotalCount($collection->getSize());

        $collection->setCurPage($criteria->getCurrentPage());
        $collection->setPageSize($criteria->getPageSize());

        $results->setItems($collection->getItems());

        return $results;
    }
}

Internally, the repository still relies on the ResourceModel, but these implementation details stay invisible to the caller. Whoever injects PostRepositoryInterface does not need to know, and cannot tell, whether underneath there is a ResourceModel, an external API, or an in-memory cache. This decoupling is the decisive advantage over the Active Record Pattern.

Migrating from Active Record to Repository

Migrating from Active Record to Repository in an existing Magento 2 project works best step by step and module by module. You start by defining the repository interface without changing the existing code. Then you implement the repository, which internally still uses the ResourceModel. Finally you replace all load() calls with Repository::getById(), one module at a time.

During migration, you need to pay particular attention to exception handling. The Active Record Pattern gives no feedback when an entity is not found, the Model is simply empty. The Repository Pattern throws NoSuchEntityException. All callers therefore need to be adjusted accordingly, to catch this exception and handle it sensibly. That is more work, but it also results in more correctness.

The preference configuration in di.xml is the connecting link between interface and implementation. Without this configuration, the DI container does not know which concrete class to instantiate for the interface. The preference definition is simple and clear: <preference for="Mironsoft\Blog\Api\PostRepositoryInterface" type="Mironsoft\Blog\Model\PostRepository"/>. After that, the interface can be used everywhere via constructor injection.

After the migration, it pays off to configure PHPStan at level 6 or higher and systematically find all remaining load() calls. PHPStan's deprecation rule set flags every deprecated API call. A fully migrated project has no more PHPStan warnings for Active Record methods. That is a measurable quality criterion that integrates well into CI/CD pipelines.

Summary: Active Record vs. Repository

The Active Record Pattern in Magento 2 (Model plus ResourceModel) has a long history, but clear limits: missing type safety, difficult testability, performance traps, and deprecated APIs. The Repository Pattern with Service Contracts is the modern way in Magento 2.4.8. AbstractModel::load() is deprecated, new code should always use Repository::getById(). Migration is possible step by step and pays off in the long run through better testability, a clearer architecture, and upgrade safety.

load() is deprecated

AbstractModel::load() deprecated since Magento 2.4. Replacement: Repository::getById(). PHPStan flags every load() call as a warning.

Repository Interface

PostRepositoryInterface with getById(), save(), delete(), getList(). Preference set in di.xml pointing to the concrete implementation.

Data Interface

PostInterface with typed getters/setters. API clients only know the interface. Implementation can be swapped without breaking changes.

Testability

Mock the repository interface with createMock(). No database access needed in the unit test. Clear advantage over model->load().

Mironsoft

Replace legacy Active Record with Repository?

We migrate Magento 2 modules from Model::load()-based Active Record to a clean Repository Pattern with Service Contracts, SearchCriteria, and full test coverage. Step by step, safe, and upgrade-compatible.

Service Contracts

Repository interfaces and Data Interfaces as a stable API layer

Migration

Replace load() calls with Repository::getById(), safely and step by step

Unit Tests

Mock repository classes and test them in isolation without database access

FAQ: Active Record Pattern in Magento 2

1 What is the Active Record Pattern in Magento 2?

The Active Record Pattern combines a database row and business logic in one object. In Magento 2 it is implemented through Model and ResourceModel: the Model delegates load(), save() and delete() to the ResourceModel, which performs the actual SQL operations. AbstractModel::load() has been deprecated since Magento 2.4.

2 Why is AbstractModel::load() deprecated?

AbstractModel::load() is deprecated because it offers no type-safe interface, mixes data access with object creation, is not reachable through Service Contracts, and makes unit testing without database access impossible. The replacement is Repository::getById() through the corresponding repository interface.

3 What is the difference between Model and ResourceModel?

The Model holds data, business logic and events (_beforeSave, _afterSave). The ResourceModel holds the database connection, executes SQL, and knows the table name and primary key. The Model delegates load/save/delete to the ResourceModel, Magento 2's variation of the pure Active Record Pattern.

4 What is a typical Active Record bug in Magento 2?

The "phantom save" problem: calling $model->load($id) without checking whether the load succeeded, then calling save(). With an invalid ID, an empty object gets saved (INSERT instead of UPDATE). The Repository Pattern prevents this through NoSuchEntityException when the entity does not exist.

5 How do you implement a repository for a custom module?

1) Define PostRepositoryInterface with getById(), save(), delete(), getList(). 2) PostRepository implements the interface using PostFactory, PostResource and CollectionFactory. 3) In di.xml, set a preference for PostRepositoryInterface pointing to PostRepository. 4) Create PostInterface for a typed data API.

6 How do you test a repository in unit tests?

Mock the repository interface: createMock(PostRepositoryInterface::class). The class using the repository receives the mock via constructor injection. No database access is needed in the unit test. That is the decisive advantage over using Model::load() directly.

7 What is a Data Interface in Magento 2?

Data Interfaces such as PostInterface define typed getters and setters for an entity. The concrete model implements the interface. API clients only know the interface, not the Model. That allows the implementation to be swapped without breaking API changes.

8 Can AbstractModel::load() still be used in Magento 2.4.8?

Technically yes, but it is deprecated. New modules should always use the repository. Legacy code should migrate gradually. PHPStan flags load() calls as a deprecated warning from level 5 onward. In Magento 2.4.8, the Active Record Pattern is no longer acceptable for new code.

9 What performance problems does the Active Record Pattern have?

SELECT * on every load() call, N+1 query problems with relations, no targeted field selection. The Repository Pattern with SearchCriteria enables optimized queries, pagination and targeted field selection. This matters especially for EAV models such as products.

10 How do you migrate from Model::load() to the Repository Pattern?

Step by step: 1) Define the repository interface. 2) Create the repository implementation. 3) Register the preference in di.xml. 4) Replace all load() calls with Repository::getById(). 5) Add exception handling with NoSuchEntityException and CouldNotSaveException. PHPStan helps find remaining load() calls.