Inhalt
- 1. Was ist das Active Record Pattern?
- 2. Magento's Abwandlung: Model + ResourceModel
- 3. AbstractModel: Daten ohne DB-Logik
- 4. ResourceModel: Die Datenbank-Schicht
- 5. CRUD-Operationen im Detail
- 6. Events und Before/After-Hooks
- 7. Eigenes Model + ResourceModel erstellen
- 8. Repository Pattern als moderne Alternative
- 9. Direktes Model vs. Repository: Wann was?
- 10. Fazit: Active Record richtig verstehen und ablösen
Wenn Magento-Entwickler $product->save() schreiben, nutzen sie das Active Record Pattern. Das Model kennt sich selbst, seinen Zustand und weiß, wie es sich in der Datenbank speichert. Martin Fowler beschrieb es 2003 in "Patterns of Enterprise Application Architecture" — und Magento 1 baute seine gesamte ORM-Schicht darauf auf. Magento 2 behielt es aus Kompatibilitätsgründen, führte aber Repositories als sauberere Alternative ein. Warum und wie — das zeigt dieser Deep Dive.
1. Was ist das Active Record Pattern?
Im klassischen Active Record Pattern ist ein Objekt gleichzeitig Domänenobjekt und 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]
Das Objekt trägt sowohl die Daten als auch die DB-Operationen. In PHP-Frameworks wie Ruby on Rails ist das der Standard. Das Problem: Enge Kopplung zwischen Domänenlogik und Persistenzschicht — schwer zu testen und zu erweitern.
2. Magento's Abwandlung: Model + ResourceModel
Magento split das klassische Active Record in zwei Klassen auf — das ist der entscheidende Unterschied:
KLASSISCHES ACTIVE RECORD: MAGENTO'S ABWANDLUNG:
+------------------+ +------------------+ +------------------+
| 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)|
+------------------+
Diese Trennung bringt Vorteile:
- Testbarkeit: Model kann ohne DB getestet werden
- Erweiterbarkeit: ResourceModel kann ausgetauscht werden
- Mehrere Backends: Theoretisch verschiedene DB-Backends möglich
- Event-System: Sauberere Before/After-Hooks im ResourceModel
3. AbstractModel: Daten ohne DB-Logik
Magento\Framework\Model\AbstractModel ist das Herz jedes Magento-Models. Es erbt von DataObject (magischer Getter/Setter) und delegiert DB-Operationen an das ResourceModel:
<?php
// vendor/magento/framework/Model/AbstractModel.php (vereinfacht)
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;
}
}
Die magischen Getter/Setter kommen von 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: Die Datenbank-Schicht
Das ResourceModel ist der eigentliche Datenbankzugriff. Jedes Model hat ein zugehöriges ResourceModel:
<?php
// vendor/magento/framework/Model/ResourceModel/Db/AbstractDb.php (vereinfacht)
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-Operationen im Detail
So sieht die vollständige CRUD-Pipeline aus:
<?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 und Before/After-Hooks
Das Active Record Pattern in Magento ist durchdrungen vom Event-System. Jede CRUD-Operation feuert mehrere Events:
<?php
// Events für save() — in chronologischer Reihenfolge:
// 1. model_save_before (generisch — alle Models)
// 2. catalog_product_save_before (spezifisch — nur Produkte)
// → In _beforeSave() im ResourceModel
// 3. SQL INSERT/UPDATE ausgeführt
// 4. catalog_product_save_after (spezifisch)
// 5. model_save_after (generisch)
// Magento-eigene 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;
}
Observer für save-Events registrieren:
<!-- 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. Eigenes Model + ResourceModel erstellen
So erstellst du ein vollständiges Model-Stack für ein eigenes Modul:
<?php
declare(strict_types=1);
namespace Mironsoft\Blog\Model;
use Magento\Framework\Model\AbstractModel;
/**
* Blog Post model — 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);
}
}
Das db_schema.xml für die Tabelle:
<!-- 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 als moderne Alternative
Magento 2 empfiehlt für neuen Code das Repository Pattern statt direktem Model-Zugriff:
<?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. Direktes Model vs. Repository: Wann was?
| Kriterium | Direktes Model ($model->save()) | Repository |
|---|---|---|
| API-Kompatibilität | ✗ Keine API-Garantien | ✓ Stabile Service Contracts |
| Testbarkeit | ✗ Benötigt echte DB | ✓ Einfach mockbar |
| Plugin-Support | ~ Über Observer | ✓ Direkte Plugins auf Interface |
| Caching | ✗ Kein eingebautes Caching | ✓ Cache in Repository implementierbar |
| GraphQL/REST | ✗ Kein direkter API-Zugriff | ✓ Automatisch via webapi.xml |
| Komplexität | ✓ Einfacher für Quick-Scripts | ~ Mehr Boilerplate nötig |
Die Entscheidungsregel:
<?php
// Direktes Model: OK für Setup-Scripts und CLI-Commands
// (Einmalig ausgeführt, kein API-Kontext, kein Testing nötig)
class InstallData implements \Magento\Framework\Setup\InstallDataInterface
{
public function install(...): void
{
$post = $this->postFactory->create();
$post->setTitle('Welcome');
$post->save(); // Akzeptabel in Setup-Scripts
}
}
// Repository: Für alle anderen Kontexte — 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. Fazit: Active Record richtig verstehen und ablösen
Das Active Record Pattern in Magento ist historisch bedingt und bleibt Teil des Frameworks. Für eigene Module solltest du es als Implementierungsdetail hinter Repositories verstecken:
✓ Active Record nutzen für
- Setup-Scripts (InstallData, UpgradeData)
- CLI-Commands und Queue-Consumer
- Interne ResourceModel-Logik
- Patch-Klassen (DataPatchInterface)
- Observer-Code (Event-Daten lesen)
✗ Repository verwenden statt Model
- Controller- und ViewModel-Code
- Service-Klassen mit Business Logic
- REST-API und GraphQL-Resolver
- Code der unit-testbar sein soll
- Code mit Plugin-Interceptoren
Zusammenfassung
Magento-Architektur verbessern
Direkte Model-Zugriffe durch Repositories ersetzen, eigene Module architektonisch sauber aufbauen.