Build a Magento 2 Extension with Repository Pattern

Build Magento 2 Extension with Repository Pattern

Build a Magento 2 Extension with Repository Pattern

Most Magento 2 extension development tutorials stop at the interface layer, hand-waving implementation details and leaving you with a broken skeleton the moment you try to filter by anything. This guide covers the full vertical slice instead. You'll wire a complete custom repository — models, resource models, service contracts, SearchCriteria support, and a test skeleton — using PHP 8.2 typed properties and copy-paste-ready code.

Compatibility note: Declarative schema (db_schema.xml) requires Magento 2.3.0+, but the PHP 8.2 typed property syntax used throughout this guide limits practical use to Magento 2.4.3 and above.

What You'll Need

- Magento 2.4.6+ / Adobe Commerce 2.4.x - PHP 8.2+ - Composer-based local development environment - Familiarity with Magento module structure (you've built a module before) - Basic understanding of Dependency Injection in Magento 2

The module we're building manages Announcement entities — a clean, real-world example without heavy business logic.

---

Key Concepts

Before diving into code, here are the four patterns this guide relies on. Understanding them upfront will make each implementation decision make sense.

- Service contracts — Magento's service contract pattern uses interfaces to separate what an entity exposes (data layer) from how it's stored (resource layer). Interfaces live in Api/ and define the public contract; concrete classes handle the details privately. - Preference binding — A di.xml directive that maps an interface to a concrete class. Magento's object manager resolves it automatically at runtime. - SearchCriteria — A standardized filter, sort, and pagination object passed to getList(). It lets callers query collections without writing raw SQL or touching the database layer. - Virtual types — Named configuration variants of existing classes, defined in di.xml. They behave like subclasses but require no new PHP file.

---

1. Module Structure, di.xml, and Schema

Store all files under app/code/Unomage/Announcements. The module uses six directories.

Unomage/Announcements/

├── Api/ │ ├── AnnouncementRepositoryInterface.php │ └── Data/ │ ├── AnnouncementInterface.php │ └── AnnouncementSearchResultsInterface.php ├── Model/ │ ├── Announcement.php │ ├── ResourceModel/ │ │ ├── Announcement.php │ │ └── Announcement/ │ │ └── Collection.php │ └── AnnouncementRepository.php ├── etc/ │ ├── di.xml │ ├── module.xml │ └── db_schema.xml ├── registration.php ├── db_schema_whitelist.json └── composer.json

registration.php registers the module with Magento's component system. Without it, Magento won't recognize that the module exists.

<?php

use Magento\Framework\Component\ComponentRegistrar;

ComponentRegistrar::register( ComponentRegistrar::MODULE, 'Unomage_Announcements', __DIR__ );

etc/di.xml is the most important configuration file in this module. A preference tag tells Magento's object manager which concrete class to inject whenever code type-hints a given interface — no manual factory wiring required. The virtualType entry creates a named SearchResult configuration for grid use without requiring a new PHP class.

<?xml version="1.0"?>

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">

<preference for="Unomage\Announcements\Api\AnnouncementRepositoryInterface" type="Unomage\Announcements\Model\AnnouncementRepository"/> <preference for="Unomage\Announcements\Api\Data\AnnouncementInterface" type="Unomage\Announcements\Model\Announcement"/> <preference for="Unomage\Announcements\Api\Data\AnnouncementSearchResultsInterface" type="Magento\Framework\Api\SearchResults"/>

<virtualType name="Unomage\Announcements\Model\ResourceModel\Announcement\Grid" type="Magento\Framework\View\Element\UiComponent\DataProvider\SearchResult"> <arguments> <argument name="mainTable" xsi:type="string">unomage_announcement</argument> <argument name="resourceModel" xsi:type="string">Unomage\Announcements\Model\ResourceModel\Announcement</argument> </arguments> </virtualType> </config>

etc/db_schema.xml defines the database table using Magento's declarative schema system, which replaces install scripts from 2.3 onward.

<?xml version="1.0"?>

<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> <table name="unomage_announcement" resource="default" engine="innodb" comment="Unomage Announcements"> <column xsi:type="int" name="announcement_id" unsigned="true" nullable="false" identity="true" comment="Announcement ID"/> <column xsi:type="varchar" name="title" nullable="false" length="255" comment="Title"/> <column xsi:type="text" name="content" nullable="true" comment="Content"/> <column xsi:type="smallint" name="is_active" unsigned="true" nullable="false" default="1" comment="Is Active"/> <column xsi:type="datetime" name="created_at" nullable="false" comment="Created At"/> <constraint xsi:type="primary" referenceId="PRIMARY"> <column name="announcement_id"/> </constraint> </table> </schema>

After creating db_schema.xml, generate the required whitelist file. Magento uses this file to track which columns your module owns, so it only applies your changes during setup:upgrade.

bin/magento setup:db-declaration:generate-whitelist --module-name=Unomage_Announcements

This creates db_schema_whitelist.json in your module root. Without it, declarative schema silently skips your column definitions on existing installations.

---

2. Defining Data Interfaces

Now that the module is registered and interfaces are wired in di.xml, define the contracts that the rest of the codebase will depend on. These interfaces contain no implementation — only constants and method signatures.

Api/Data/AnnouncementInterface.php defines every property your entity exposes. The static return type on setters keeps fluent chains working correctly through inheritance, which is the PHP 8.2 idiomatic approach.

<?php

declare(strict_types=1);

namespace Unomage\Announcements\Api\Data;

interface AnnouncementInterface { public const ANNOUNCEMENT_ID = 'announcement_id'; public const TITLE = 'title'; public const CONTENT = 'content'; public const IS_ACTIVE = 'is_active'; public const CREATED_AT = 'created_at';

public function getAnnouncementId(): ?int; public function setAnnouncementId(int $id): static; public function getTitle(): string; public function setTitle(string $title): static; public function getContent(): ?string; public function setContent(?string $content): static; public function isActive(): bool; public function setIsActive(bool $isActive): static; public function getCreatedAt(): ?string; public function setCreatedAt(string $createdAt): static; }

Api/AnnouncementRepositoryInterface.php declares every operation the repository must support, using the typed interfaces defined above.

<?php

declare(strict_types=1);

namespace Unomage\Announcements\Api;

use Magento\Framework\Api\SearchCriteriaInterface; use Unomage\Announcements\Api\Data\AnnouncementInterface; use Unomage\Announcements\Api\Data\AnnouncementSearchResultsInterface;

interface AnnouncementRepositoryInterface { public function save(AnnouncementInterface $announcement): AnnouncementInterface; public function getById(int $announcementId): AnnouncementInterface; public function getList(SearchCriteriaInterface $searchCriteria): AnnouncementSearchResultsInterface; public function delete(AnnouncementInterface $announcement): bool; public function deleteById(int $announcementId): bool; }

Define a typed SearchResultsInterface rather than reusing Magento's base version. This gives you a properly typed getItems() return, which matters when REST serialization inspects your results.

<?php

declare(strict_types=1);

namespace Unomage\Announcements\Api\Data;

use Magento\Framework\Api\SearchResultsInterface;

interface AnnouncementSearchResultsInterface extends SearchResultsInterface { /* @return AnnouncementInterface[] / public function getItems(): array;

/* @param AnnouncementInterface[] $items / public function setItems(array $items): static; }

---

3. Implementing Models and the Repository

With contracts defined, implement the concrete persistence layer. Each class has exactly one responsibility.

Model/Announcement.php implements AnnouncementInterface using getData/setData storage inherited from AbstractModel. Explicit casts in the getters ensure the typed interface contract is always satisfied, regardless of what the database returns as a raw string.

<?php

declare(strict_types=1);

namespace Unomage\Announcements\Model;

use Magento\Framework\Model\AbstractModel; use Unomage\Announcements\Api\Data\AnnouncementInterface; use Unomage\Announcements\Model\ResourceModel\Announcement as AnnouncementResource;

class Announcement extends AbstractModel implements AnnouncementInterface { protected function _construct(): void { $this->_init(AnnouncementResource::class); }

public function getAnnouncementId(): ?int { return $this->getData(self::ANNOUNCEMENT_ID) !== null ? (int) $this->getData(self::ANNOUNCEMENT_ID) : null; }

public function setAnnouncementId(int $id): static { return $this->setData(self::ANNOUNCEMENT_ID, $id); }

public function getTitle(): string { return (string) $this->getData(self::TITLE); } public function setTitle(string $title): static { return $this->setData(self::TITLE, $title); } public function getContent(): ?string { return $this->getData(self::CONTENT); } public function setContent(?string $content): static { return $this->setData(self::CONTENT, $content); } public function isActive(): bool { return (bool) $this->getData(self::IS_ACTIVE); } public function setIsActive(bool $isActive): static { return $this->setData(self::IS_ACTIVE, $isActive); } public function getCreatedAt(): ?string { return $this->getData(self::CREATED_AT); } public function setCreatedAt(string $createdAt): static { return $this->setData(self::CREATED_AT, $createdAt); } }

Model/ResourceModel/Announcement.php maps the model to its database table and primary key.

<?php

declare(strict_types=1);

namespace Unomage\Announcements\Model\ResourceModel;

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

class Announcement extends AbstractDb { protected function _construct(): void { $this->_init('unomage_announcement', 'announcement_id'); } }

Model/ResourceModel/Announcement/Collection.php binds the model and resource model together. The CollectionProcessorInterface calls _construct() to know which tables and models to apply SearchCriteria filters against.

<?php

declare(strict_types=1);

namespace Unomage\Announcements\Model\ResourceModel\Announcement;

use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection; use Unomage\Announcements\Model\Announcement; use Unomage\Announcements\Model\ResourceModel\Announcement as AnnouncementResource;

class Collection extends AbstractCollection { protected string $_idFieldName = 'announcement_id';

protected function _construct(): void { $this->_init(Announcement::class, AnnouncementResource::class); } }

Model/AnnouncementRepository.php is where SearchCriteria actually gets applied. The $collectionProcessor->process() call translates filter groups, sort orders, and page size from the SearchCriteriaInterface object into SQL conditions on the collection — no manual addFieldToFilter() loops required.

<?php

declare(strict_types=1);

namespace Unomage\Announcements\Model;

use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; use Magento\Framework\Api\SearchCriteriaInterface; use Magento\Framework\Exception\CouldNotDeleteException; use Magento\Framework\Exception\CouldNotSaveException; use Magento\Framework\Exception\NoSuchEntityException; use Unomage\Announcements\Api\AnnouncementRepositoryInterface; use Unomage\Announcements\Api\Data\AnnouncementInterface; use Unomage\Announcements\Api\Data\AnnouncementSearchResultsInterface; use Unomage\Announcements\Api\Data\AnnouncementSearchResultsInterfaceFactory; use Unomage\Announcements\Model\ResourceModel\Announcement as AnnouncementResource; use Unomage\Announcements\Model\ResourceModel\Announcement\CollectionFactory;

class AnnouncementRepository implements AnnouncementRepositoryInterface { /* @var AnnouncementInterface[] / private array $cache = [];

public function __construct( private readonly AnnouncementFactory $announcementFactory, private readonly AnnouncementResource $resource, private readonly CollectionFactory $collectionFactory, private readonly AnnouncementSearchResultsInterfaceFactory $searchResultsFactory, private readonly CollectionProcessorInterface $collectionProcessor, ) {}

public function save(AnnouncementInterface $announcement): AnnouncementInterface { try { $this->resource->save($announcement); $this->cache[$announcement->getAnnouncementId()] = $announcement; } catch (\Exception $e) { throw new CouldNotSaveException(__($e->getMessage())); } return $announcement; }

public function getById(int $announcementId): AnnouncementInterface { if (isset($this->cache[$announcementId])) { return $this->cache[$announcementId]; } $announcement = $this->announcementFactory->create(); $this->resource->load($announcement, $announcementId); if (!$announcement->getAnnouncementId()) { throw new NoSuchEntityException( __('Announcement with ID "%1" does not exist.', $announcementId) ); } return $this->cache[$announcementId] = $announcement; }

public function getList(SearchCriteriaInterface $searchCriteria): AnnouncementSearchResultsInterface { $collection = $this->collectionFactory->create(); // Translates filter groups, sort orders, and page limits into collection SQL conditions $this->collectionProcessor->process($searchCriteria, $collection); $searchResults = $this->searchResultsFactory->create(); $searchResults->setSearchCriteria($searchCriteria); $searchResults->setItems($collection->getItems()); $searchResults->setTotalCount($collection->getSize()); return $searchResults; }

public function delete(AnnouncementInterface $announcement): bool { try { $id = $announcement->getAnnouncementId(); $this->resource->delete($announcement); unset($this->cache[$id]); } catch (\Exception $e) { throw new CouldNotDeleteException(__($e->getMessage())); } return true; }

public function deleteById(int $announcementId): bool { return $this->delete($this->getById($announcementId)); } }

The $cache array is a simple in-process identity map. It prevents duplicate database queries within the same request, which matters in observer chains or loops that call getById() repeatedly.

---

4. Testing Your Repository

Magento 2 repository testing gets skipped constantly. Here are concrete skeletons for both integration and unit coverage.

Integration tests verify the full stack against a real database. Each test method covers one repository operation end to end, using the object manager to resolve the real wired dependencies.

<?php

declare(strict_types=1);

namespace Unomage\Announcements\Test\Integration\Model;

use Magento\Framework\Api\SearchCriteriaBuilder; use Magento\TestFramework\Helper\Bootstrap; use PHPUnit\Framework\TestCase; use Unomage\Announcements\Api\AnnouncementRepositoryInterface; use Unomage\Announcements\Api\Data\AnnouncementInterfaceFactory;

class AnnouncementRepositoryTest extends TestCase { private AnnouncementRepositoryInterface $repository; private AnnouncementInterfaceFactory $factory; private SearchCriteriaBuilder $searchCriteriaBuilder;

protected function setUp(): void { $objectManager = Bootstrap::getObjectManager(); $this->repository = $objectManager->get(AnnouncementRepositoryInterface::class); $this->factory = $objectManager->get(AnnouncementInterfaceFactory::class); $this->searchCriteriaBuilder = $objectManager->get(SearchCriteriaBuilder::class); }

public function testSaveAndRetrieve(): void { $announcement = $this->factory->create(); $announcement->setTitle('Test Announcement'); $announcement->setContent('Hello from integration test'); $announcement->setIsActive(true); $announcement->setCreatedAt(date('Y-m-d H:i:s'));

$saved = $this->repository->save($announcement); $this->assertNotNull($saved->getAnnouncementId());

$fetched = $this->repository->getById((int) $saved->getAnnouncementId()); $this->assertSame('Test Announcement', $fetched->getTitle()); }

public function testGetListFiltersCorrectly(): void { $criteria = $this->searchCriteriaBuilder ->addFilter('is_active', 1) ->create();

$results = $this->repository->getList($criteria); $this->assertGreaterThanOrEqual(0, $results->getTotalCount()); foreach ($results->getItems() as $item) { $this->assertTrue($item->isActive()); } }

public function testDeleteById(): void { $announcement = $this->factory->create(); $announcement->setTitle('To Be Deleted'); $announcement->setIsActive(false); $announcement->setCreatedAt(date('Y-m-d H:i:s'));

$saved = $this->repository->save($announcement); $id = (int) $saved->getAnnouncementId(); $this->repository->deleteById($id);

$this->expectException(\Magento\Framework\Exception\NoSuchEntityException::class); $this->repository->getById($id); } }

Unit tests isolate the repository from the database, verifying logic like cache hits and exception throwing without a real connection.

<?php

declare(strict_types=1);

namespace Unomage\Announcements\Test\Unit\Model;

use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface; use Magento\Framework\Exception\NoSuchEntityException; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Unomage\Announcements\Api\Data\AnnouncementSearchResultsInterfaceFactory; use Unomage\Announcements\Model\Announcement; use Unomage\Announcements\Model\AnnouncementFactory; use Unomage\Announcements\Model\AnnouncementRepository; use Unomage\Announcements\Model\ResourceModel\Announcement as AnnouncementResource; use Unomage\Announcements\Model\ResourceModel\Announcement\CollectionFactory;

class AnnouncementRepositoryTest extends TestCase { private AnnouncementRepository $repository; private AnnouncementResource&MockObject $resource; private AnnouncementFactory&MockObject $factory; private Announcement&MockObject $model;

protected function setUp(): void { $this->resource = $this->createMock(AnnouncementResource::class); $this->factory = $this->createMock(AnnouncementFactory::class); $this->model = $this->createMock(Announcement::class);

$this->repository = new AnnouncementRepository( announcementFactory: $this->factory, resource: $this->resource, collectionFactory: $this->createMock(CollectionFactory::class), searchResultsFactory: $this->createMock(AnnouncementSearchResultsInterfaceFactory::class), collectionProcessor: $this->createMock(CollectionProcessorInterface::class), ); }

public function testGetByIdThrowsOnMissingEntity(): void { $this->factory->method('create')->willReturn($this->model); $this->model->method('getAnnouncementId')->willReturn(null);

$this->expectException(NoSuchEntityException::class); $this->repository->getById(999); } }

Warning: Integration tests require a fully installed Magento test database. Run them with vendor/bin/phpunit -c dev/tests/integration/phpunit.xml against a dedicated test environment — never a production database.

---

Wrapping Up

That's the full vertical slice — from di.xml preference wiring and declarative schema through typed PHP 8.2 interfaces, a SearchCriteria-compatible repository, and working test coverage.

A few things worth keeping in mind as you build on this:

- Extension attributes belong in a separate etc/extension_attributes.xml if third-party modules need to attach data to your entity. - Caching — the in-process $cache array works for request scope. For full-page or block caching, you'll need to tag and invalidate separately. - REST API exposure is already half-done. Add etc/webapi.xml routes pointing to your repository interface methods, and Magento generates the REST layer automatically — the real payoff of proper service contracts. - Injecting the repository into controllers or other services requires no special configuration. Any constructor that type-hints AnnouncementRepositoryInterface will automatically receive your concrete implementation via the preference binding in di.xml.

The patterns here mirror how Magento's own core modules — Products, Categories, Orders — are structured. Use this as your reference, not your starting point for reinvention.

---

Key Terms

- Service contracts — Interfaces in the Api/ directory that define a module's public API, decoupling callers from implementation details. - Preference binding — A di.xml directive that maps an interface to a concrete class, resolved automatically by Magento's object manager. - SearchCriteria — A standardized filter, sort, and pagination object passed to getList() to query collections without writing raw SQL. - Virtual types — Named configuration variants of existing classes defined in di.xml, requiring no new PHP file. - Declarative schema — Magento's XML-driven database schema system (2.3.0+), replacing install/upgrade scripts with db_schema.xml and a generated db_schema_whitelist.json.

Quality Score: 72/100



Jot us a note and we’ll get back to you as quickly as possible.
Copyright © 2021-2026 Unomage, LLC. All rights reserved.