Contents
- 1. What is the Active Record Pattern?
- 2. Magento's Adaptation: Model + ResourceModel
- 3. AbstractModel: Data Without DB Logic
- 4. ResourceModel: The Database Layer
- 5. CRUD Operations in Detail
- 6. Events and Before/After Hooks
- 7. Building Your Own Model + ResourceModel
- 8. Repository Pattern as a Modern Alternative
- 9. Direct Model vs. Repository: When to Use What?
- 10. Conclusion: Understanding and Replacing Active Record Properly
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
Improving Magento Architecture
Replace direct model access with repositories, build your own modules with clean architecture from the ground up.