Active Record Pattern in Magento 2: Models and Their Database Connection | Mironsoft
AI generated
MODEL
DB
Deep Dive · Magento 2 Design Patterns

Active Record Pattern:
Magento Models and Their Database Connection

How AbstractModel and ResourceModel work together, why Magento adapts the classic Active Record Pattern, and when Repositories are the better choice.

14 min read
Magento 2.4.8 · PHP 8.4
GoF + Fowler Enterprise Pattern

When Magento developers write $product->save(), they are using the Active Record Pattern. The model knows itself, its state, and how to persist itself to the database. Martin Fowler described it in 2003 in "Patterns of Enterprise Application Architecture," and Magento 1 built its entire ORM layer on it. Magento 2 kept it for compatibility reasons but introduced Repositories as a cleaner alternative. Why and how, this deep dive explains.

1. What is the Active Record Pattern?

In the classic Active Record Pattern, an object is simultaneously a domain object and a DB wrapper:


+----------------------------------+
|         ActiveRecord             |
|----------------------------------|
| - id: int                        |
| - name: string                   |
| - email: string                  |
|----------------------------------|
| + find(id): static               |  ← SELECT
| + findAll(criteria): array       |  ← SELECT ... WHERE
| + save(): void                   |  ← INSERT or UPDATE
| + delete(): void                 |  ← DELETE
| + validate(): bool               |  ← Business Logic
+----------------------------------+
         ↕ direct DB access
    [users table in database]
    

The object carries both the data and the DB operations. In PHP frameworks like Ruby on Rails, this is the standard. The problem: tight coupling between domain logic and the persistence layer, which makes it hard to test and extend.

2. Magento's Adaptation: Model + ResourceModel

Magento splits the classic Active Record into two classes, and that is the decisive difference:


CLASSIC ACTIVE RECORD:               MAGENTO'S ADAPTATION:
+------------------+                 +------------------+    +------------------+
| Product          |                 | Product Model    |    | Product Resource |
| + getData()      |                 | + getData()      |    | + load()         |
| + setData()      |                 | + setData()      |    | + save()         |
| + load()         |                 | + getName()      |    | + delete()       |
| + save()         |                 | + setName()      |    | + _getWriteAd.() |
| + delete()       |                 +------------------+    +------------------+
+------------------+                        ↕ delegates             ↕
       ↕                              +------------------+    [ catalog_product ]
  [ database ]                       | Resource Model   |    [ _entity table   ]
                                     | (injected via DI)|
                                     +------------------+
    

This separation brings advantages:

  • Testability: the model can be tested without a DB
  • Extensibility: the ResourceModel can be swapped out
  • Multiple backends: theoretically, different DB backends are possible
  • Event system: cleaner before/after hooks in the ResourceModel

3. AbstractModel: Data Without DB Logic

Magento\Framework\Model\AbstractModel is the heart of every Magento model. It inherits from DataObject (magic getters/setters) and delegates DB operations to the ResourceModel:


<?php

// vendor/magento/framework/Model/AbstractModel.php (simplified)

namespace Magento\Framework\Model;

use Magento\Framework\DataObject;
use Magento\Framework\Model\ResourceModel\AbstractResource;

abstract class AbstractModel extends DataObject
{
    /** @var AbstractResource The resource model, actual DB access */
    protected $_resource;

    /** @var string Primary key field name */
    protected $_idFieldName = 'id';

    /** @var bool Has the model been loaded from DB? */
    protected $_hasDataChanges = false;

    public function __construct(
        protected readonly \Magento\Framework\Model\Context $context,
        protected readonly \Magento\Framework\Registry $registry,
        protected readonly AbstractResource $resource,
        protected readonly \Magento\Framework\Data\Collection\AbstractDb $resourceCollection,
        array $data = [],
    ) {
        parent::__construct($data);
        $this->_resource = $resource;
        $this->_init();
    }

    /**
     * LOAD: Delegates to ResourceModel
     * Fires event: model_load_before, {prefix}_load_before
     */
    public function load(int|string $modelId, string $field = null): static
    {
        $this->_beforeLoad($modelId, $field);
        $this->_getResource()->load($this, $modelId, $field); // ← ResourceModel
        $this->_afterLoad();
        $this->setOrigData();
        $this->_hasDataChanges = false;
        return $this;
    }

    /**
     * SAVE: Delegates to ResourceModel
     * Fires events: model_save_before, {prefix}_save_before,
     *               model_save_after, {prefix}_save_after
     */
    public function save(): static
    {
        $this->_getResource()->save($this); // ← ResourceModel
        return $this;
    }

    /**
     * DELETE: Delegates to ResourceModel
     */
    public function delete(): static
    {
        $this->_getResource()->delete($this);
        return $this;
    }

    /**
     * Returns the resource model instance.
     */
    protected function _getResource(): AbstractResource
    {
        return $this->_resource;
    }
}
    

The magic getters/setters come from DataObject:


<?php

// DataObject magic: getData/setData
$product->setName('iPhone 15');          // → setData('name', 'iPhone 15')
$product->getName();                      // → getData('name')
$product->setSku('IPHONE-15-128');       // → setData('sku', ...)
$product->getPrice();                     // → getData('price')

// Direct data access
$product->setData('custom_field', 'val');
$product->getData('custom_field');
$product->getData(); // Returns entire data array

// Check if changed (important for save optimization)
$product->hasDataChanges(); // true if any setter was called since load
    

4. ResourceModel: The Database Layer

The ResourceModel is the actual database access. Every model has an associated ResourceModel:


<?php

// vendor/magento/framework/Model/ResourceModel/Db/AbstractDb.php (simplified)

namespace Magento\Framework\Model\ResourceModel\Db;

abstract class AbstractDb extends \Magento\Framework\Model\ResourceModel\AbstractResource
{
    /** @var string Main table name */
    protected $_mainTable;

    /** @var string Primary key field */
    protected $_idFieldName = 'entity_id';

    /**
     * Initialize resource model, called in constructor
     * Must call $this->_init() with table name and primary key
     */
    abstract protected function _construct(): void;

    /**
     * LOAD: SELECT by primary key or custom field
     */
    public function load(
        \Magento\Framework\Model\AbstractModel $object,
        mixed $value,
        string $field = null
    ): static {
        $field ??= $this->getIdFieldName();
        $connection = $this->getConnection();

        $select = $connection->select()
            ->from($this->getMainTable())
            ->where("{$field} = ?", $value);

        $data = $connection->fetchRow($select);

        if ($data) {
            $object->setData($data); // Hydrate the model
        }

        $this->unserializeFields($object);
        $this->_afterLoad($object);

        return $this;
    }

    /**
     * SAVE: INSERT or UPDATE depending on id presence
     */
    public function save(\Magento\Framework\Model\AbstractModel $object): static
    {
        $this->beginTransaction();
        try {
            $this->_beforeSave($object);

            if (!$object->getId() || $object->isObjectNew()) {
                $this->_saveNewObject($object); // INSERT
            } else {
                $this->_updateObject($object); // UPDATE
            }

            $this->_afterSave($object);
            $this->commit();
        } catch (\Exception $e) {
            $this->rollBack();
            throw $e;
        }

        return $this;
    }

    /**
     * DELETE: DELETE FROM table WHERE id = ?
     */
    public function delete(\Magento\Framework\Model\AbstractModel $object): static
    {
        $this->beginTransaction();
        try {
            $this->_beforeDelete($object);

            $condition = $this->getConnection()->quoteInto(
                $this->getIdFieldName() . '=?',
                $object->getId()
            );
            $this->getConnection()->delete($this->getMainTable(), $condition);

            $this->_afterDelete($object);
            $this->commit();
        } catch (\Exception $e) {
            $this->rollBack();
            throw $e;
        }

        return $this;
    }
}
    

5. CRUD Operations in Detail

This is what the complete CRUD pipeline looks like:


<?php

declare(strict_types=1);

use Magento\Catalog\Model\ProductFactory;
use Magento\Catalog\Model\ResourceModel\Product as ProductResource;

// CREATE
$productFactory = /* injected */;
$product = $productFactory->create();
$product->setData([
    'name'       => 'New Product',
    'sku'        => 'NEW-001',
    'price'      => 99.99,
    'status'     => 1,
    'visibility' => 4,
    'type_id'    => 'simple',
    'attribute_set_id' => 4,
]);
$product->save(); // INSERT INTO catalog_product_entity ...
$newId = $product->getId(); // Auto-assigned after INSERT

// READ (load by ID)
$loadedProduct = $productFactory->create();
$loadedProduct->load($newId);
echo $loadedProduct->getName(); // 'New Product'

// READ (load by field)
$bySkuProduct = $productFactory->create();
$bySkuProduct->load('NEW-001', 'sku'); // SELECT WHERE sku = 'NEW-001'

// UPDATE
$loadedProduct->setPrice(79.99);
$loadedProduct->setName('Updated Product');
$loadedProduct->save(); // UPDATE catalog_product_entity SET ... WHERE entity_id = X

// DELETE
$loadedProduct->delete(); // DELETE FROM catalog_product_entity WHERE entity_id = X
    

6. Events and Before/After Hooks

The Active Record Pattern in Magento is permeated by the event system. Every CRUD operation fires multiple events:


<?php

// Events for save() in chronological order:

// 1. model_save_before            (generic, all models)
// 2. catalog_product_save_before  (specific, products only)
//    → In _beforeSave() in the ResourceModel
// 3. SQL INSERT/UPDATE executed
// 4. catalog_product_save_after   (specific)
// 5. model_save_after             (generic)

// Magento's own hooks in AbstractModel:
protected function _beforeLoad(int|string $id, string $field = null): static
{
    $this->_eventManager->dispatch(
        'model_load_before',
        ['object' => $this, 'field' => $field, 'value' => $id]
    );
    $this->_eventManager->dispatch(
        $this->_eventPrefix . '_load_before',
        [$this->_eventObject => $this, 'field' => $field, 'value' => $id]
    );
    return $this;
}
    

Registering observers for save events:


<!-- app/code/Mironsoft/Catalog/etc/events.xml -->
<config>
    <event name="catalog_product_save_after">
        <observer
            name="mironsoft_catalog_product_save_after"
            instance="Mironsoft\Catalog\Observer\ProductSaveAfter"
        />
    </event>
    <event name="catalog_product_delete_before">
        <observer
            name="mironsoft_catalog_product_delete_before"
            instance="Mironsoft\Catalog\Observer\ProductDeleteBefore"
        />
    </event>
</config>
    

<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Catalog\Model\Product;

/**
 * Invalidates external cache after product save.
 */
final class ProductSaveAfter implements ObserverInterface
{
    public function __construct(
        private readonly \Psr\Log\LoggerInterface $logger,
    ) {}

    public function execute(Observer $observer): void
    {
        /** @var Product $product */
        $product = $observer->getEvent()->getProduct();

        $this->logger->info('Product saved', [
            'sku'     => $product->getSku(),
            'id'      => $product->getId(),
            'changed' => $product->getChangedProductIds(),
        ]);
    }
}
    

7. Building Your Own Model + ResourceModel

Here is how to build a complete model stack for your own module:


<?php

declare(strict_types=1);

namespace Mironsoft\Blog\Model;

use Magento\Framework\Model\AbstractModel;

/**
 * Blog Post model, a data container with Active Record capability.
 */
class Post extends AbstractModel
{
    protected $_eventPrefix = 'mironsoft_blog_post';
    protected $_eventObject = 'post';

    /**
     * Initializes the model with its resource model.
     */
    protected function _construct(): void
    {
        $this->_init(\Mironsoft\Blog\Model\ResourceModel\Post::class);
    }

    // Typed convenience methods (optional but recommended)
    public function getTitle(): string
    {
        return (string) $this->getData('title');
    }

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

    public function getContent(): string
    {
        return (string) $this->getData('content');
    }

    public function isPublished(): bool
    {
        return (bool) $this->getData('is_published');
    }
}
    

<?php

declare(strict_types=1);

namespace Mironsoft\Blog\Model\ResourceModel;

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

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

    /**
     * Custom before-save validation.
     */
    protected function _beforeSave(\Magento\Framework\Model\AbstractModel $object): static
    {
        if (!$object->getTitle()) {
            throw new \Magento\Framework\Exception\LocalizedException(
                __('Blog post title is required.')
            );
        }

        if (!$object->getCreatedAt()) {
            $object->setCreatedAt((new \DateTime())->format('Y-m-d H:i:s'));
        }

        $object->setUpdatedAt((new \DateTime())->format('Y-m-d H:i:s'));

        return parent::_beforeSave($object);
    }
}
    

<?php

declare(strict_types=1);

namespace Mironsoft\Blog\Model\ResourceModel\Post;

use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
use Mironsoft\Blog\Model\Post;
use Mironsoft\Blog\Model\ResourceModel\Post as PostResource;

/**
 * Blog Post collection.
 */
class Collection extends AbstractCollection
{
    protected function _construct(): void
    {
        $this->_init(Post::class, PostResource::class);
    }
}
    

The db_schema.xml for the table:


<!-- app/code/Mironsoft/Blog/etc/db_schema.xml -->
<schema>
    <table name="mironsoft_blog_post" resource="default" engine="innodb"
           comment="Blog Posts">
        <column xsi:type="int" name="post_id" unsigned="true" nullable="false"
                identity="true" comment="Post ID"/>
        <column xsi:type="varchar" name="title" length="255" nullable="false"
                comment="Post Title"/>
        <column xsi:type="text" name="content" nullable="true"
                comment="Post Content"/>
        <column xsi:type="smallint" name="is_published" unsigned="true"
                nullable="false" default="0" comment="Is Published"/>
        <column xsi:type="datetime" name="created_at" nullable="false"
                comment="Created At"/>
        <column xsi:type="datetime" name="updated_at" nullable="false"
                comment="Updated At"/>
        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="post_id"/>
        </constraint>
        <index referenceId="MIRONSOFT_BLOG_POST_IS_PUBLISHED" indexType="btree">
            <column name="is_published"/>
        </index>
    </table>
</schema>
    

8. Repository Pattern as a Modern Alternative

For new code, Magento 2 recommends the Repository Pattern instead of direct model access:


<?php

declare(strict_types=1);

namespace Mironsoft\Blog\Model;

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

/**
 * Blog Post repository, service contract implementation.
 * Wraps the Active Record model with a clean API.
 */
final class PostRepository implements PostRepositoryInterface
{
    public function __construct(
        private readonly PostFactory $postFactory,
        private readonly PostResource $resource,
        private readonly CollectionFactory $collectionFactory,
        private readonly PostSearchResultsInterfaceFactory $searchResultsFactory,
        private readonly CollectionProcessorInterface $collectionProcessor,
    ) {}

    /**
     * {@inheritdoc}
     */
    public function getById(int $postId): PostInterface
    {
        $post = $this->postFactory->create();
        $this->resource->load($post, $postId);

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

        return $post;
    }

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

        return $post;
    }

    /**
     * {@inheritdoc}
     */
    public function delete(PostInterface $post): bool
    {
        try {
            $this->resource->delete($post);
        } catch (\Exception $e) {
            throw new \Magento\Framework\Exception\CouldNotDeleteException(
                __('Could not delete blog post: %1', $e->getMessage())
            );
        }

        return true;
    }

    /**
     * {@inheritdoc}
     */
    public function getList(SearchCriteriaInterface $searchCriteria): PostSearchResultsInterface
    {
        $collection = $this->collectionFactory->create();
        $this->collectionProcessor->process($searchCriteria, $collection);

        $searchResults = $this->searchResultsFactory->create();
        $searchResults->setSearchCriteria($searchCriteria);
        $searchResults->setItems($collection->getItems());
        $searchResults->setTotalCount($collection->getSize());

        return $searchResults;
    }
}
    

9. Direct Model vs. Repository: When to Use What?

Criterion Direct Model ($model->save()) Repository
API compatibility ✗ No API guarantees ✓ Stable service contracts
Testability ✗ Requires a real DB ✓ Easy to mock
Plugin support ~ Via observer ✓ Direct plugins on interface
Caching ✗ No built-in caching ✓ Cache implementable in repository
GraphQL/REST ✗ No direct API access ✓ Automatic via webapi.xml
Complexity ✓ Simpler for quick scripts ~ More boilerplate required

The decision rule:


<?php

// Direct model: OK for setup scripts and CLI commands
// (run once, no API context, no testing needed)
class InstallData implements \Magento\Framework\Setup\InstallDataInterface
{
    public function install(...): void
    {
        $post = $this->postFactory->create();
        $post->setTitle('Welcome');
        $post->save(); // Acceptable in setup scripts
    }
}

// Repository: for all other contexts, services, controllers, GraphQL
class PostController extends \Magento\Framework\App\Action\Action
{
    public function execute(): \Magento\Framework\Controller\ResultInterface
    {
        $postId = (int) $this->getRequest()->getParam('id');
        $post = $this->postRepository->getById($postId); // Repository
        // ...
    }
}
    

10. Conclusion: Understanding and Replacing Active Record Properly

The Active Record Pattern in Magento is historically motivated and remains part of the framework. For your own modules, you should hide it as an implementation detail behind repositories:

✓ Use Active Record for

  • Setup scripts (InstallData, UpgradeData)
  • CLI commands and queue consumers
  • Internal ResourceModel logic
  • Patch classes (DataPatchInterface)
  • Observer code (reading event data)

✗ Use Repository instead of Model

  • Controller and ViewModel code
  • Service classes with business logic
  • REST API and GraphQL resolvers
  • Code that should be unit-testable
  • Code with plugin interceptors

Summary

Pattern
Magento's Active Record separates data (AbstractModel) from DB logic (ResourceModel), an improvement over the classic pattern
Events
Every CRUD operation fires before/after events, model_save_before, {prefix}_save_after, for extensions without preferences
Modernization
Repositories wrap the Active Record model and deliver stable service contracts, plugin support, and testability
Rule
$model->save() only in setup scripts and CLI. In all other code: use the repository interface

Improving Magento Architecture

Replace direct model access with repositories, build your own modules with clean architecture from the ground up.

????️
Repository Refactoring
Replacing legacy model code with service contracts
????
Unit Tests
Making repository-based code testable with PHPUnit
????
Module Development
Clean model architecture from the ground up for new modules

Frequently Asked Questions About the Active Record Pattern in Magento

What is the Active Record Pattern in Magento 2? +
Magento's Active Record combines AbstractModel (data plus business logic) with ResourceModel (DB operations). The call $product->save() internally delegates to the ResourceModel, which executes the SQL INSERT or UPDATE.
What is the difference between Model and ResourceModel? +
The Model is a data container with magic getters/setters and business logic, without SQL. The ResourceModel is the actual DB layer with SELECT/INSERT/UPDATE/DELETE and transaction handling.
Why should I avoid $model->save()? +
Repositories offer stable service contracts, easy mock testability, plugin support directly on interfaces, and automatic REST/GraphQL API support. Direct save() has none of these benefits.
When is direct $model->save() acceptable? +
In setup scripts, CLI commands, and queue consumers, direct save() is acceptable. In controllers, services, and ViewModel, always use a Repository.
What events does Magento fire on $model->save()? +
model_save_before → {prefix}_save_before → SQL execution → {prefix}_save_after → model_save_after. The prefix is defined in the class via $_eventPrefix.
How do I create my own Model in Magento? +
Three classes: Model (extends AbstractModel, calls _init(ResourceModel::class)), ResourceModel (extends AbstractDb, calls _init('table_name', 'pk')), Collection (extends AbstractCollection, calls _init(Model, ResourceModel)).
What does _beforeSave() do in the ResourceModel? +
_beforeSave() is called before the INSERT/UPDATE, the right place for validation and automatic field assignment (created_at, updated_at). On exception, the transaction is rolled back. Parent::_beforeSave() must be called.
How does $model->load() work internally? +
load() fires load_before events, calls ResourceModel::load(), which executes SELECT WHERE id = ?, retrieves fetchRow(), and calls $object->setData($row) to hydrate the model.
Why does Magento use DataObject as the base for Models? +
DataObject implements magic __get/__set, getName() → getData('name'), setPrice(99) → setData('price', 99). This makes models flexible for dynamic EAV attributes without explicit methods.
Does Magento's ResourceModel support transactions? +
Yes. save() and delete() are automatically wrapped in transactions: beginTransaction() before the operation, commit() on success, rollBack() on exception.