Repository is a pattern that separates business logic from the details of data access. Instead of scattering SQL queries or ORM calls throughout the application, you have one class responsible for fetching and saving entities. In Magento 2 Repository is part of Service Contracts – a standard, not an option. I show how to build your own Repository from scratch and why it is worth doing.
The problem without Repository
<?php
// Business logic mixed with data access
class OrderService
{
public function getActiveOrders(int $customerId): array
{
// SQL directly in the service - violates SRP
$stmt = $this->pdo->prepare(
'SELECT * FROM orders WHERE customer_id = ? AND status = "active"'
);
$stmt->execute([$customerId]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
}
Testing this code requires a real database. Changing the data source requires modifying business logic.
Repository Interface – the contract
<?php
declare(strict_types=1);
namespace Vendor\Module\Api;
use Vendor\Module\Api\Data\OrderInterface;
interface OrderRepositoryInterface
{
/** @throws \Magento\Framework\Exception\NoSuchEntityException */
public function getById(int $id): OrderInterface;
/** @return OrderInterface[] */
public function getActiveByCustomer(int $customerId): array;
public function save(OrderInterface $order): OrderInterface;
public function delete(OrderInterface $order): bool;
public function deleteById(int $id): bool;
}
Repository implementation
<?php
declare(strict_types=1);
namespace Vendor\Module\Model;
use Vendor\Module\Api\OrderRepositoryInterface;
use Vendor\Module\Api\Data\OrderInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\CouldNotDeleteException;
class OrderRepository implements OrderRepositoryInterface
{
private array $cache = [];
public function __construct(
private \Vendor\Module\Model\ResourceModel\Order $resource,
private \Vendor\Module\Api\Data\OrderInterfaceFactory $orderFactory,
private \Vendor\Module\Model\ResourceModel\Order\CollectionFactory $collectionFactory
) {}
public function getById(int $id): OrderInterface
{
if (isset($this->cache[$id])) return $this->cache[$id];
$order = $this->orderFactory->create();
$this->resource->load($order, $id);
if (!$order->getId()) {
throw new NoSuchEntityException(__('Order with ID "%1" does not exist.', $id));
}
return $this->cache[$id] = $order;
}
public function getActiveByCustomer(int $customerId): array
{
return $this->collectionFactory->create()
->addFieldToFilter('customer_id', $customerId)
->addFieldToFilter('status', 'active')
->setOrder('created_at', 'DESC')
->getItems();
}
public function save(OrderInterface $order): OrderInterface
{
try {
$this->resource->save($order);
if ($order->getId()) $this->cache[$order->getId()] = $order;
} catch (\Exception $e) {
throw new CouldNotSaveException(__('Could not save order: %1', $e->getMessage()), $e);
}
return $order;
}
public function delete(OrderInterface $order): bool
{
try {
$id = $order->getId();
$this->resource->delete($order);
unset($this->cache[$id]);
} catch (\Exception $e) {
throw new CouldNotDeleteException(__('Could not delete order: %1', $e->getMessage()), $e);
}
return true;
}
public function deleteById(int $id): bool
{
return $this->delete($this->getById($id));
}
}
Repository with SearchCriteria – paginated filtered lists
<?php
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Api\SearchResultsInterface;
class OrderRepository implements OrderRepositoryInterface
{
public function __construct(
// ... previous dependencies
private \Magento\Framework\Api\SearchResultsInterfaceFactory $searchResultsFactory,
private \Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface $collectionProcessor
) {}
public function getList(SearchCriteriaInterface $searchCriteria): SearchResultsInterface
{
$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;
}
}
// Usage via SearchCriteriaBuilder
$sc = $this->searchCriteriaBuilder
->addFilter('status', 'active')
->addFilter('created_at', '2020-01-01', 'gt')
->addSortOrder($this->sortOrderBuilder->setField('created_at')->setDescendingDirection()->create())
->setPageSize(20)
->setCurrentPage(1)
->create();
$result = $this->orderRepository->getList($sc);
echo 'Found: ' . $result->getTotalCount() . ' orders';
Testing Repository with a mock
<?php
use PHPUnit\Framework\TestCase;
class OrderServiceTest extends TestCase
{
public function testCancelOrderChangesStatus(): void
{
$order = $this->createMock(OrderInterface::class);
$order->expects($this->once())->method('setStatus')->with('cancelled');
$repository = $this->createMock(OrderRepositoryInterface::class);
$repository->method('getById')->with(42)->willReturn($order);
$repository->expects($this->once())->method('save')->with($order);
$service = new OrderService($repository);
$service->cancelOrder(42);
}
}
Summary
Repository is one of the most important patterns for building testable PHP code. It separates business logic from persistence details, centralises data access, and enables testing without a database. In Magento 2 it is part of the module’s formal contract – implementing your own Repository according to the platform standard makes your module predictable for other developers and ecosystem tooling.
