Writing the Model, Resource Model, and Collection for the Points Ledger
Writing the Model, Resource Model, and Collection for the Points Ledger
~7 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
With the table from chapter 3 in place, the data foundation exists - now the three classes that the rest of the PHP code uses to access it are needed: the model for a single ledger entry, the resource model for reading and writing, and the collection for set-based queries with filtering and sorting. This chapter deliberately keeps all three lean - chapter 6 later upgrades the model with an interface once the repository idea is introduced.
Model
The model extends AbstractModel and binds to its resource model in _construct() - nothing more is needed at this point.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model;
use Magento\Framework\Model\AbstractModel;
use Mironsoft\Loyalty\Model\ResourceModel\PointsLedger as PointsLedgerResource;
/**
* Points ledger entity model, represents a single, immutable ledger entry.
*/
class PointsLedger extends AbstractModel
{
/**
* Binds the model to its resource model.
*
* @return void
*/
protected function _construct(): void
{
$this->_init(PointsLedgerResource::class);
}
}Resource model
The resource model knows the table name and the primary key column - both come directly from db_schema.xml.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
/**
* Resource model for the points ledger, maps the entity onto
* mironsoft_loyalty_points_ledger.
*/
class PointsLedger extends AbstractDb
{
/**
* Initializes the main table and primary key column.
*
* @return void
*/
protected function _construct(): void
{
$this->_init('mironsoft_loyalty_points_ledger', 'ledger_id');
}
}Collection
The collection binds model and resource model together and gets two small but important extra methods: addCustomerFilter() filters down to a single customer, addNewestFirstOrder() orders by creation date descending. Both get reused repeatedly starting in chapter 6.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\ResourceModel\PointsLedger;
use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
use Mironsoft\Loyalty\Model\PointsLedger as PointsLedgerModel;
use Mironsoft\Loyalty\Model\ResourceModel\PointsLedger as PointsLedgerResource;
/**
* Collection of points ledger entries.
*/
class Collection extends AbstractCollection
{
/**
* Binds the collection to its model and resource model pair.
*
* @return void
*/
protected function _construct(): void
{
$this->_init(PointsLedgerModel::class, PointsLedgerResource::class);
}
/**
* Restricts the collection to ledger entries of a single customer.
*
* @param int $customerId Customer entity ID.
* @return $this
*/
public function addCustomerFilter(int $customerId): self
{
$this->addFieldToFilter('customer_id', ['eq' => $customerId]);
return $this;
}
/**
* Orders the collection by creation date, most recent entry first.
*
* @return $this
*/
public function addNewestFirstOrder(): self
{
$this->setOrder('created_at', self::SORT_ORDER_DESC);
return $this;
}
}Achtung: addFieldToFilter('customer_id', $customerId) without the ['eq' => ...] array happens to work in many cases too, because Magento internally tries to interpret the value - but it isn't reliable, and PHPStan level 5 flags the bare scalar form as an error in this project. The array form is also the only one that reliably distinguishes between "equals", "not equals", "greater than", and other operators.
Why no interface yet?
CLAUDE.md calls for service contracts and repositories - but writing an interface for a single class before a second implementation or a repository even exists would be premature abstraction. Chapter 5 first builds the PointsCalculator service, which works completely independently of these three classes. Only chapter 6 introduces PointsLedgerInterface and PointsLedgerRepositoryInterface - and upgrades PointsLedger to match in a single step.