The Data Layer
The table, the model trio, the data interface, the repository and a data patch that seeds the first rows.
bin/magento setup:db-declaration:generate-whitelist --module-name=Acme_LocatorThe data layer comes first, because the admin grid, the storefront page and both APIs all read the same objects.
The table
etc/db_schema.xml describes the table as it should be. On bin/magento setup:upgrade Magento compares that description with the live database and applies the difference. Every table, column and index name also needs an entry in etc/db_schema_whitelist.json, which you generate rather than write by hand.
The trio
Three classes per entity. Model\Location extends Magento\Framework\Model\AbstractModel and holds one row's data. Model\ResourceModel\Location extends Magento\Framework\Model\ResourceModel\Db\AbstractDb and knows the table name and the primary key. Model\ResourceModel\Location\Collection loads many rows. Each declares what it maps to by calling _init() inside _construct().
Interface and repository
Api\Data\LocationInterface declares the getters, the setters and one constant per column. Api\LocationRepositoryInterface declares getById, getList, save and deleteById. The repository is the only entry point the rest of the module uses, so nothing outside Model touches the resource model. etc/di.xml maps each interface to its implementation with a preference.
Seed rows
A data patch under Setup/Patch/Data inserts the starting rows through the repository. It runs once: Magento records the class name in the patch_list table and skips it afterwards. Editing an applied patch does nothing.
setup:upgrade applies the schema and the patches. After adding interfaces, run setup:di:compile so the generated factories exist.
Example
<!-- app/code/Acme/Locator/etc/db_schema.xml -->
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="acme_locator_location" resource="default" engine="innodb" comment="Pickup Locations">
<column xsi:type="int" name="location_id" unsigned="true" nullable="false" identity="true"
comment="Location ID"/>
<column xsi:type="varchar" name="name" nullable="false" length="255" comment="Location Name"/>
<column xsi:type="varchar" name="identifier" nullable="false" length="255" comment="URL Key"/>
<column xsi:type="varchar" name="city" nullable="true" length="128" comment="City"/>
<column xsi:type="smallint" name="is_active" unsigned="false" nullable="false" default="1"
comment="Is Active"/>
<column xsi:type="timestamp" name="created_at" on_update="false" nullable="false"
default="CURRENT_TIMESTAMP" comment="Created At"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="location_id"/>
</constraint>
<constraint xsi:type="unique" referenceId="ACME_LOCATOR_LOCATION_IDENTIFIER">
<column name="identifier"/>
</constraint>
<index referenceId="ACME_LOCATOR_LOCATION_IS_ACTIVE" indexType="btree">
<column name="is_active"/>
</index>
</table>
</schema>
<!--
What changes: setup:upgrade creates acme_locator_location with these
columns, or alters an existing table to match. This file is the desired
state, not a migration, so later lessons edit it in place rather than
adding a second file.
Apply it with:
bin/magento setup:db-declaration:generate-whitelist --module-name=Acme_Locator
bin/magento setup:upgrade
-->When to use it
- Add a custom entity with its own table, repository and REST-ready interface.
- Seed default rows for a module so a fresh install is usable without manual admin work.
- Give a grid, a storefront page and an API one shared read path instead of three collection queries.
More examples
The resource model, and what the other two classes _init to
This is the only class that names the table. The model, app/code/Acme/Locator/Model/Location.php, extends Magento\Framework\Model\AbstractModel and calls $this->_init(\Acme\Locator\Model\ResourceModel\Location::class) so it knows how to load and save itself. The collection, Model/ResourceModel/Location/Collection.php, extends Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection and calls $this->_init(\Acme\Locator\Model\Location::class, \Acme\Locator\Model\ResourceModel\Location::class). Note the method is _construct with one underscore, which Magento calls from its own __construct; writing __construct here instead loses the parent's dependencies.
<?php
// app/code/Acme/Locator/Model/ResourceModel/Location.php
declare(strict_types=1);
namespace Acme\Locator\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
class Location extends AbstractDb
{
protected function _construct()
{
$this->_init('acme_locator_location', 'location_id');
}
}The repository: getById and getList
CollectionProcessorInterface turns a SearchCriteria into filters, sorting and paging on the collection, so getList needs no hand-written WHERE clause and no concatenated values. Its process() method takes (SearchCriteriaInterface $searchCriteria, Magento\Framework\Data\Collection\AbstractDb $collection) and returns nothing; the collection is modified in place. The constructor uses PHP 8 promotion, which is shorthand: `public function __construct(private readonly LocationResource $resource)` is the same as declaring `private readonly LocationResource $resource;` as a property and writing `$this->resource = $resource;` in a plain constructor body. Magento's own code mostly uses the long form, so you will meet both. LocationFactory and the SearchResults factory are generated classes; setup:di:compile writes them into generated/code.
<?php
// app/code/Acme/Locator/Model/LocationRepository.php
declare(strict_types=1);
namespace Acme\Locator\Model;
use Acme\Locator\Api\Data\LocationInterface;
use Acme\Locator\Api\Data\LocationSearchResultsInterface;
use Acme\Locator\Api\Data\LocationSearchResultsInterfaceFactory;
use Acme\Locator\Api\LocationRepositoryInterface;
use Acme\Locator\Model\ResourceModel\Location as LocationResource;
use Acme\Locator\Model\ResourceModel\Location\CollectionFactory;
use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Exception\NoSuchEntityException;
class LocationRepository implements LocationRepositoryInterface
{
public function __construct(
private readonly LocationResource $resource,
private readonly LocationFactory $locationFactory,
private readonly CollectionFactory $collectionFactory,
private readonly CollectionProcessorInterface $collectionProcessor,
private readonly LocationSearchResultsInterfaceFactory $searchResultsFactory
) {
}
public function getById(int $locationId): LocationInterface
{
$location = $this->locationFactory->create();
$this->resource->load($location, $locationId);
if ($location->getId() === null) {
throw new NoSuchEntityException(__('Location with id "%1" does not exist.', $locationId));
}
return $location;
}
public function getList(SearchCriteriaInterface $searchCriteria): LocationSearchResultsInterface
{
$collection = $this->collectionFactory->create();
$this->collectionProcessor->process($searchCriteria, $collection);
$results = $this->searchResultsFactory->create();
$results->setSearchCriteria($searchCriteria);
$results->setItems($collection->getItems());
$results->setTotalCount($collection->getSize());
return $results;
}
}A data patch for the seed rows
DataPatchInterface requires apply(), getAliases() and the static getDependencies(), which returns the class names of patches that must run first. bin/magento setup:upgrade runs it and writes Acme\Locator\Setup\Patch\Data\AddDefaultLocations into the patch_list table, so it never runs twice. Reach for a patch whenever a module needs rows to exist: a default config value, a customer group, a CMS block. To add more seed rows later, write a second patch class rather than editing this one, and implement Magento\Framework\Setup\Patch\PatchRevertableInterface if the rows should be removed on module uninstall.
<?php
// app/code/Acme/Locator/Setup/Patch/Data/AddDefaultLocations.php
declare(strict_types=1);
namespace Acme\Locator\Setup\Patch\Data;
use Acme\Locator\Api\Data\LocationInterfaceFactory;
use Acme\Locator\Api\LocationRepositoryInterface;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
class AddDefaultLocations implements DataPatchInterface
{
public function __construct(
private readonly ModuleDataSetupInterface $moduleDataSetup,
private readonly LocationRepositoryInterface $locationRepository,
private readonly LocationInterfaceFactory $locationFactory
) {
}
public function apply(): self
{
$this->moduleDataSetup->startSetup();
$rows = [
['Mitte Store', 'mitte-store', 'Berlin'],
['Old Town Store', 'old-town-store', 'Edinburgh'],
];
foreach ($rows as [$name, $identifier, $city]) {
$location = $this->locationFactory->create();
$location->setName($name);
$location->setIdentifier($identifier);
$location->setCity($city);
$location->setIsActive(true);
$this->locationRepository->save($location);
}
$this->moduleDataSetup->endSetup();
return $this;
}
public static function getDependencies(): array
{
return [];
}
public function getAliases(): array
{
return [];
}
}
Discussion