Magento 2: CRUD Models, Repositories & Interfaces

Magento 2 Logo

CRUD, (Create, Read, Update, Delete) models are used to manage data contained in the database, simple implementations can be written quite quickly, ideally, the basic CRUD implementation would be backed by corresponding interfaces. It’s always best to work with the interface as opposed to concrete implementation.

It is recommended that you read the following posts before starting this one as this post will use elements created is previous posts.

The CRUD models will be based on the database table created in Magento 2: Declarative Database Schema.

We create a Post model, Resource Model, and Resource Model Collection for managing the Post data.
We will also create the Post Interface, Post Resource Model Interface, and Collection Interface.
The full process can seem a little long-winded at first but it’s worthwhile following best practice guidelines.

Model Interface

The model interface declares the fields and methods that are available on the model implementation that we will add next.
Add the following directory and file:

app/code/Examples/FirstModule/Api/Data/PostInterface.php

Now add the following code to the file:

<?php

namespace Examples\FirstModule\Api\Data;

interface PostInterface
{
    /**
     * Constants for keys of data array. Identical to the name of the getter in snake case
     */
    const POST_ID = 'post_id';
    const AUTHOR_NAME = 'author_name';
    const EMAIL = 'email';
    const CONTENT = 'content';
    const CREATED = 'created';
    const UPDATED = 'updated';

    /**
     * @return int
     */
    public function getPostId(): int;

    /**
     * @return string
     */
    public function getAuthorName(): string;

    /**
     * @return string
     */
    public function getEmail(): string;

    /**
     * @return string
     */
    public function getContent(): string;

    /**
     * Get created at
     *
     * @return string
     */
    public function getCreatedAt(): string;

    /**
     * Get created at
     *
     * @return string
     */
    public function getUpdatedAt(): string;

    /**
     * @param int $postId
     * @return PostInterface
     */
    public function setPostId(int $postId): PostInterface;

    /**
     * @param string $authorName
     * @return PostInterface
     */
    public function setAuthorName(string $authorName): PostInterface;

    /**
     * @param string $email
     * @return PostInterface
     */
    public function setEmail(string $email): PostInterface;

    /**
     * @param string $content
     * @return PostInterface
     */
    public function setContent(string $content): PostInterface;

    /**
     * Get created at
     *
     * @param int $createdAt
     * @return PostInterface
     */
    public function setCreatedAt(int $createdAt): PostInterface;

    /**
     * Get created at
     *
     * @param int $updatedAt
     * @return PostInterface
     */
    public function setUpdatedAt(int $updatedAt): PostInterface;
}

This interface maps directly to the available database fields on the the examples_firstmodule_post table. The method getters and setter types are also mapped, with the only exceptions being updated and created accepting strings, it’s easy to pass date("Y-m-d H:i:s") to the setters and Magento will handle conversations for you.

Add Model

Now we will add the Post model, go ahead and add the following directory and file

app/code/Examples/FirstModule/Model/Post.php

Now add the following code to the new file:

<?php

namespace Examples\FirstModule\Model;

use Examples\FirstModule\Api\Data\PostInterface;
use Magento\Framework\DataObject\IdentityInterface;
use Magento\Framework\Model\AbstractExtensibleModel;

class Post extends AbstractExtensibleModel implements PostInterface, IdentityInterface
{
    const CACHE_TAG = 'examples_first_module_post';

    protected function _construct()
    {
        $this->_init(ResourceModel\Post::class);
        $this->setIdFieldName('post_id');
    }

    public function getIdentities(): array
    {
        return [self::CACHE_TAG . '_' . $this->getPostId()];
    }

    public function getPostId(): int
    {
        return $this->getData(self::POST_ID);
    }

    public function getAuthorName(): string
    {
        return $this->getData(self::AUTHOR_NAME);
    }

    public function getEmail(): string
    {
        return $this->getData(self::EMAIL);
    }

    public function getContent(): string
    {
        return $this->getData(self::CONTENT);
    }

    public function getCreatedAt(): string
    {
        return $this->getData(self::CREATED);
    }

    public function getUpdatedAt(): string
    {
        return $this->getData(self::UPDATED);
    }

    public function setPostId(int $postId): PostInterface
    {
        return $this->setData(self::POST_ID, $postId);
    }

    public function setAuthorName(string $authorName): PostInterface
    {
        return $this->setData(self::AUTHOR_NAME, $authorName);
    }

    public function setEmail(string $email): PostInterface
    {
        return $this->setData(self::EMAIL, $email);
    }

    public function setContent(string $content): PostInterface
    {
        return $this->setData(self::CONTENT, $content);
    }

    public function setCreatedAt(string $createdAt): PostInterface
    {
        return $this->setData(self::CREATED, $createdAt);
    }

    public function setUpdatedAt(string $updatedAt): PostInterface
    {
        return $this->setData(self::UPDATED, $updatedAt);
    }

    /**
     * Prepare data before saving
     *
     * @return PostInterface
     */
    public function beforeSave(): PostInterface
    {
        if ($this->hasDataChanges()) {
            $this->setUpdatedAt(date("Y-m-d H:i:s"));
        }
        return parent::beforeSave();
    }
}

Notice that this Class implements the interface created in the previous step. The _construct requires the Resouce Model and primary key declared on the database. We will create the resource model next. One additional method has been added for handling the ‘updated at’ time, extending the parent Class functionality before the Post model is saved.

A note on IdentityInterface: This interface will force the model class to define the getIdentities() method which should return a unique id for the model. You must only use this interface if your model requires the cache to be cleared after a database operation in order to render updated information to the front-end display.

Resource Model

The resource model is responsible for executing database queries. Create the following directory and file:

app/code/Examples/FirstModule/Model/ResourceModel/Post.php

Now add the following code to the file:

<?php

namespace Examples\FirstModule\Model\ResourceModel;

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

class Post extends AbstractDb
{
    protected function _construct()
    {
        $this->_init('examples_firstmodule_post', 'post_id');
    }
}

Collection Model

This model allows us to implement filtering and sorting of a collection of items when retrieving data from the database.

Create the following directory and file:

app/code/Examples/FirstModule/Model/ResourceModel/Post/Collection.php

Add the following code to the new file:

<?php

namespace Examples\FirstModule\Model\ResourceModel\Post;

use Examples\FirstModule\Model\Post as PostModel;
use Examples\FirstModule\Model\ResourceModel\Post as PostResourceModel;
use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;

class Collection extends AbstractCollection
{
    
    /**
     * Define model / resource model
     *
     * @return void
     */
    protected function _construct()
    {
        $this->_init(PostModel::class, PostResourceModel::class);
    }
}

Repository Interface

When you want to interact with the database to Get, Save or Delete a model, the repository interface should be injected into your class constructor for use.

Add the following dirstory and file:

app/etc/Examples/FirstModule/Api/PostRepositoryInterface.php

Add the following code to the new file:

<?php

namespace Examples\FirstModule\Api;

use Examples\FirstModule\Api\Data\PostInterface;
use Examples\FirstModule\Api\Data\PostSearchResultsInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Api\SearchCriteriaInterface;

interface PostRepositoryInterface
{

    /**
     * Delete post.
     *
     * @param PostInterface $post
     * @return bool true on success
     * @throws LocalizedException
     */
    public function delete(PostInterface $post): bool;

    /**
     * Delete post by ID.
     *
     * @param int $postId
     * @return bool true on success
     * @throws NoSuchEntityException
     * @throws LocalizedException
     */
    public function deleteById(int $postId): bool;

    /**
     * Retrieve post.
     *
     * @param int $postId
     * @return PostInterface
     * @throws LocalizedException
     */
    public function getById(int $postId): PostInterface;

    /**
     * Retrieve posts matching the specified criteria.
     *
     * @param SearchCriteriaInterface $searchCriteria
     * @return PostSearchResultsInterface
     * @throws LocalizedException
     */
    public function getList(SearchCriteriaInterface $searchCriteria);

    /**
     * Save post.
     *
     * @param PostInterface $post
     * @return PostInterface
     * @throws LocalizedException
     */
    public function save(PostInterface $post): PostInterface;
}

Repository Implementation

Add the following file:

app/code/Examples/FirstModule/Model/PostRepository.php

Add the following code:

<?php

namespace Examples\FirstModule\Model;

use Examples\FirstModule\Api\Data\PostInterface;
use Examples\FirstModule\Api\Data\PostInterfaceFactory;
use Examples\FirstModule\Api\Data\PostSearchResultsInterface;
use Examples\FirstModule\Api\Data\PostSearchResultsInterfaceFactory;
use Examples\FirstModule\Api\PostRepositoryInterface;
use Examples\FirstModule\Model\ResourceModel\Post as ResourcePost;
use Examples\FirstModule\Model\ResourceModel\Post\CollectionFactory as PostCollectionFactory;
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\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException;

/**
 * Post repository
 *
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
 */
class PostRepository implements PostRepositoryInterface
{
    /**
     * @var CollectionProcessorInterface
     */
    private $collectionProcessor;
    /**
     * @var ResourcePost
     */
    private $resource;
    /**
     * @var PostCollectionFactory
     */
    private $collectionFactory;
    /**
     * @var PostFactory
     */
    private $postFactory;
    /**
     * @var PostInterfaceFactory
     */
    private $postInterfaceFactory;
    /**
     * @var PostSearchResultsInterfaceFactory
     */
    private $searchResultsFactory;

    /**
     * @param ResourcePost $resource
     * @param PostFactory $postFactory
     * @param PostInterfaceFactory $postInterfaceFactory
     * @param PostCollectionFactory $collectionFactory
     * @param PostSearchResultsInterfaceFactory $searchResultsFactory
     * @param CollectionProcessorInterface $collectionProcessor
     */
    public function __construct(
        ResourcePost                      $resource,
        PostFactory                       $postFactory,
        PostInterfaceFactory              $postInterfaceFactory,
        PostCollectionFactory             $collectionFactory,
        PostSearchResultsInterfaceFactory $searchResultsFactory,
        CollectionProcessorInterface      $collectionProcessor
    ) {
        $this->resource = $resource;
        $this->postFactory = $postFactory;
        $this->collectionFactory = $collectionFactory;
        $this->searchResultsFactory = $searchResultsFactory;
        $this->postInterfaceFactory = $postInterfaceFactory;
        $this->collectionProcessor = $collectionProcessor;
    }

    /**
     * @param int $postId
     * @return bool
     * @throws CouldNotDeleteException
     */
    public function deleteById(int $postId): bool
    {
        return $this->delete($this->getById($postId));
    }

    /**
     * @param PostInterface $post
     * @return bool
     */
    public function delete(PostInterface $post): bool
    {
        try {
            $this->resource->delete($post);
        } catch (\Exception $exception) {
            throw new CouldNotDeleteException(
                __('Could not delete the post: %1', $exception->getMessage())
            );
        }
        return true;
    }

    /**
     * @param int $postId
     * @return PostInterface
     */
    public function getById(int $postId): PostInterface
    {
        $post = $this->postFactory->create();
        $this->resource->load($post, $postId);
        if (!$post->getId()) {
            throw new NoSuchEntityException(__('The post with the "%1" ID doesn\'t exist.', $postId));
        }

        return $post;
    }

    /**
     * @param SearchCriteriaInterface $searchCriteria
     * @return PostSearchResultsInterface
     */
    public function getList(SearchCriteriaInterface $searchCriteria)
    {
        $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;
    }

    /**
     * @param PostInterface $post
     * @return PostInterface
     * @throws CouldNotSaveException
     */
    public function save(PostInterface $post): PostInterface
    {
        try {
            $this->resource->save($post);
        } catch (LocalizedException $exception) {
            throw new CouldNotSaveException(
                __('Could not save the post: %1', $exception->getMessage()),
                $exception
            );
        } catch (\Throwable $exception) {
            throw new CouldNotSaveException(
                __('Could not save the post: %1', __('Something went wrong while saving the post.')),
                $exception
            );
        }
        return $post;
    }
}

Search Results Interface

Add the following file:

app/code/Examples/FirstModule/Api/Data/PostSearchResultsInterface.php

Add the following code:

<?php

namespace Examples\FirstModule\Api\Data;

use Magento\Framework\Api\SearchResultsInterface;

interface PostSearchResultsInterface extends SearchResultsInterface
{
    /**
     * Get post list.
     *
     * @return PostInterface[]
     */
    public function getItems(): array;

    /**
     * Set post list.
     *
     * @param PostInterface[] $items
     * @return $this
     */
    public function setItems(array $items);
}

Add di.xml Interface Preferences

Add the following file:

app/code/Examples/FirstModule/etc/di.xml

Add the following code:

<?xml version="1.0" encoding="utf-8" ?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <preference for="Examples\FirstModule\Api\PostRepositoryInterface" type="Examples\FirstModule\Model\PostRepository" />
    <preference for="Examples\FirstModule\Api\Data\PostInterface" type="Examples\FirstModule\Model\Post" />
    <preference for="Examples\FirstModule\Api\Data\PostSearchResultsInterface" type="Magento\Framework\Api\SearchResults" />
</config>

Complete

That should be everything required to implement your CRUD models, repository, and interfaces.

Using The Model & Repository

If you have already followed Magento 2: Adding A New Admin Page you could now extend this by doing the following.

You will need to manually add some test data to the examples_firstmodule_posts table in order for it to display any results!

INSERT INTO examples_firstmodule_post(post_id, author_name, email, content, created, updated) VALUES(1, 'Test Author', 'test@test.com', 'Some content', '2022-06-14 07:58:37.000', '2022-06-14 07:58:37.000');

Block Data Provider

Add the following file:

app/code/Examples/FirstModule/Block/Adminhtml/Examles/Posts.php

Add the following code:

<?php

namespace Examples\FirstModule\Block\Adminhtml\Examples;

use Examples\FirstModule\Api\Data\PostInterface;
use Magento\Backend\Block\Template\Context;
use Magento\Directory\Helper\Data as DirectoryHelper;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Json\Helper\Data as JsonHelper;

class Posts extends \Magento\Backend\Block\Template
{
    /**
     * @var \Magento\Framework\Api\FilterBuilder
     */
    private $filterBuilder;
    /**
     * @var \Examples\FirstModule\Api\PostRepositoryInterface
     */
    private $postRepository;
    /**
     * @var \Magento\Framework\Api\SearchCriteriaBuilder
     */
    private $searchCriteriaBuilder;

    public function __construct(
        Context                                           $context,
        \Examples\FirstModule\Api\PostRepositoryInterface $postRepository,
        \Magento\Framework\Api\SearchCriteriaBuilder      $searchCriteriaBuilder,
        \Magento\Framework\Api\FilterBuilder              $filterBuilder,
        array                                             $data = [],
        ?JsonHelper                                       $jsonHelper = null,
        ?DirectoryHelper                                  $directoryHelper = null
    ) {
        parent::__construct($context, $data, $jsonHelper, $directoryHelper);
        $this->postRepository = $postRepository;
        $this->searchCriteriaBuilder = $searchCriteriaBuilder;
        $this->filterBuilder = $filterBuilder;
    }

    /**
     * @return PostInterface[]
     * @throws LocalizedException
     */
    public function getPosts(): array
    {
        $filter = $this->filterBuilder->setField(PostInterface::POST_ID)
            ->setConditionType('gt')
            ->setValue(0)
            ->create();
        $searchCriteria = $this->searchCriteriaBuilder->addFilters([$filter])->create();
        return $this->postRepository->getList($searchCriteria)->getItems();
    }
}

Update Page Layout

Update the following file:

app/code/Examples/FirstModule/view/adminhtml/layout/examples_examples_posts.xml

Update the code to the following:

<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <update handle="styles"/>
    <body>
        <referenceContainer name="content">
            <block class="Examples\FirstModule\Block\Adminhtml\Examples\Posts" template="Examples_FirstModule::posts.phtml"/>
        </referenceContainer>
    </body>
</page>

Update Template

Update the following file:

app/code/Examples/FirstModule/view/adminhtml/templates/posts.phtml

Update the code to the following:

<?php
/** @var \Examples\FirstModule\Block\Adminhtml\Examples\Posts $block */
$posts = $block->getPosts();
?>

<h2>Examples_FirstModule post content template</h2>
<p>Congratulations if you are seeing this you have successfully implemented your page and template.</p>

<br/>
    <style>
        table, th, td {
            border:1px solid black;
            padding: 5px;
            text-align: left
        }
    </style>
    <table style="width:100%;">
        <tr>
            <th>Post Id</th>
            <th>Author</th>
            <th>Email</th>
            <th>Content</th>
            <th>Created</th>
            <th>Updated</th>
        </tr>

        <?php foreach ($posts as $post): ?>
            <tr>
                <td><?= $post->getPostId(); ?></td>
                <td><?= $post->getAuthorName(); ?></td>
                <td><?= $post->getEmail(); ?></td>
                <td><?= $post->getContent(); ?></td>
                <td><?= $post->getCreatedAt(); ?></td>
                <td><?= $post->getUpdatedAt(); ?></td>
            </tr>
        <?php endforeach; ?>
    </table>

Result

When navigating to your admin posts page it should display the following:

Resulting page and post data display

Leave a Reply

Your email address will not be published. Required fields are marked *