Skip to content
intermediate Phase 117 · Intermediate Projects

Project - Admin CRUD Module

Build a complete admin CRUD module with grid, form, data provider, mass actions, and full entity management

1h 30m
0 problems
Topic Progress 0%

Module Setup and Database Schema

Module Structure

app/code/Vendor/Newsletter/
├── registration.php
├── etc/
│   ├── module.xml
│   ├── adminhtml.xml
│   ├── acl.xml
│   ├── di.xml
│   └── db_schema.xml
├── Setup/
│   └── Patch/
│       └── Data/
│           └── AddStatusAttribute.php
├── Model/
│   ├── Newsletter.php
│   ├── ResourceModel/
│   │   ├── Newsletter.php
│   │   └── Collection.php
│   └── Data/
│       └── NewsletterInterface.php
├── Api/
│   ├── NewsletterRepositoryInterface.php
│   └── Data/
│       └── NewsletterDataInterface.php
├── Controller/Adminhtml/
│   ├── Newsletter/
│   │   ├── NewAction.php
│   │   ├── Edit.php
│   │   ├── Save.php
│   │   ├── Delete.php
│   │   ├── MassDelete.php
│   │   └── MassStatus.php
│   └── Index.php
├── Block/
│   ├── Adminhtml/
│   │   ├── Newsletter/
│   │   │   ├── Grid.php
│   │   │   ├── Edit/Form.php
│   │   │   └── Edit/Tab/Main.php
│   │   └── Newsletter/Edit.php
│   └── Newsletter/
│       └── Grid.php
└── view/
    └── adminhtml/
        ├── layout/
        │   └── adminhtml_newsletter_grid.xml
        └── ui_component/
            └── newsletter_listing.xml

registration.php

<?php
use Magento\Framework\Component\ComponentRegistrar;

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

module.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="Vendor_Newsletter" setup_version="1.0.0">
        <sequence>
            <module name="Magento_Backend"/>
            <module name="Magento_Ui"/>
            <module name="Magento_Search"/>
        </sequence>
    </module>
</config>

Database Schema

<!-- etc/db_schema.xml -->
<?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="vendor_newsletter" resource="default" engine="innodb" comment="Newsletter Subscribers">
        <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" comment="Entity ID"/>
        <column xsi:type="varchar" name="email" nullable="false" length="255" comment="Email"/>
        <column xsi:type="varchar" name="first_name" nullable="true" length="255" comment="First Name"/>
        <column xsi:type="varchar" name="last_name" nullable="true" length="255" comment="Last Name"/>
        <column xsi:type="smallint" name="status" unsigned="true" nullable="false" default="1" comment="Status"/>
        <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/>
        <column xsi:type="timestamp" name="updated_at" on_update="true" nullable="false" default="CURRENT_TIMESTAMP" comment="Updated At"/>
        <constraint referenceType="primary">
            <column name="entity_id"/>
        </constraint>
        <constraint referenceType="unique" indexType="btree">
            <column name="email"/>
        </constraint>
        <index referenceType="btree" indexType="btree">
            <column name="status"/>
        </index>
    </table>
</schema>

Declarative Data Patch

<?php
namespace Vendor\Newsletter\Setup\Patch\Data;

use Magento\Framework\Setup\Patch\DataPatchInterface;
use Magento\Framework\Setup\ModuleDataSetupInterface;

class AddStatusAttribute implements DataPatchInterface
{
    public function __construct(
        private ModuleDataSetupInterface $setup,
    ) {
    }

    public function apply(): void
    {
        $this->setup->getConnection()->startSetup();
        // Additional setup logic here
        $this->setup->getConnection()->endSetup();
    }

    public function getAliases(): array
    {
        return [];
    }

    public function getDependencies(): array
    {
        return [];
    }
}

Data Model and Resource Model

Data Interface

<?php
namespace Vendor\Newsletter\Model\Data;

interface NewsletterInterface
{
    const ENTITY_ID = 'entity_id';
    const EMAIL = 'email';
    const FIRST_NAME = 'first_name';
    const LAST_NAME = 'last_name';
    const STATUS = 'status';
    const CREATED_AT = 'created_at';
    const UPDATED_AT = 'updated_at';

    const STATUS_ACTIVE = 1;
    const STATUS_INACTIVE = 0;

    public function getId(): ?int;
    public function setId(int $id): self;
    public function getEmail(): string;
    public function setEmail(string $email): self;
    public function getFirstName(): ?string;
    public function setFirstName(?string $firstName): self;
    public function getLastName(): ?string;
    public function setLastName(?string $lastName): self;
    public function getStatus(): int;
    public function setStatus(int $status): self;
    public function getCreatedAt(): ?string;
    public function getUpdatedAt(): ?string;
}

Model

<?php
namespace Vendor\Newsletter\Model;

use Magento\Framework\Model\AbstractModel;
use Vendor\Newsletter\Model\Data\NewsletterInterface;
use Vendor\Newsletter\Model\ResourceModel\Newsletter as NewsletterResource;

class Newsletter extends AbstractModel implements NewsletterInterface
{
    protected function _construct(): void
    {
        $this->_init(NewsletterResource::class);
    }

    public function getId(): ?int
    {
        return $this->getData(self::ENTITY_ID);
    }

    public function setId(int $id): NewsletterInterface
    {
        return $this->setData(self::ENTITY_ID, $id);
    }

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

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

    public function getFirstName(): ?string
    {
        return $this->getData(self::FIRST_NAME);
    }

    public function setFirstName(?string $firstName): NewsletterInterface
    {
        return $this->setData(self::FIRST_NAME, $firstName);
    }

    public function getLastName(): ?string
    {
        return $this->getData(self::LAST_NAME);
    }

    public function setLastName(?string $lastName): NewsletterInterface
    {
        return $this->setData(self::LAST_NAME, $lastName);
    }

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

    public function setStatus(int $status): NewsletterInterface
    {
        return $this->setData(self::STATUS, $status);
    }

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

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

Resource Model

<?php
namespace Vendor\Newsletter\Model\ResourceModel;

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

class Newsletter extends AbstractDb
{
    protected function _construct(): void
    {
        $this->_init('vendor_newsletter', 'entity_id');
    }

    protected function _beforeSave(\Magento\Framework\Model\AbstractModel $object): AbstractDb
    {
        if ($object->getEmail() && !$object->getId()) {
            $object->setUpdatedAt(null);
        }
        return parent::_beforeSave($object);
    }
}

Collection

<?php
namespace Vendor\Newsletter\Model\ResourceModel;

use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
use Vendor\Newsletter\Model\Newsletter as NewsletterModel;
use Vendor\Newsletter\Model\ResourceModel\Newsletter as NewsletterResource;

class Collection extends AbstractCollection
{
    protected $idFieldName = 'entity_id';

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

    public function addActiveFilter(): self
    {
        $this->addFieldToFilter('status', ['eq' => NewsletterModel::STATUS_ACTIVE]);
        return $this;
    }
}

Admin Grid with UI Component

UI Component Listing

<!-- view/adminhtml/ui_component/newsletter_listing.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
    <argument name="data" xsi:type="array">
        <item name="js_config" xsi:type="array">
            <item name="component" xsi:type="string">Magento_Ui/js/grid/provider</item>
        </item>
    </argument>

    <settings>
        <spinner>newsletter_columns</spinner>
        <deps>
            <dep>newsletter_listing.newsletter_columns</dep>
        </deps>
    </settings>

    <dataSource name="newsletter_listing_data_source" component="Magento_Ui/js/grid/provider">
        <settings>
            <updateUrl path="mui/index/render"/>
        </settings>
        <aclResource>Vendor_Newsletter::newsletter</aclResource>
        <dataProvider class="Magento\Ui\DataProvider\Mod\DataProvider\ModDataProvider" name="newsletter_listing_data_source">
            <settings>
                <requestFieldName>id</requestFieldName>
                <dataProviderSettings>
                    <addFilterClauseBind>email</addFilterClauseBind>
                    <addFilterClauseBind>status</addFilterClauseBind>
                </dataProviderSettings>
            </settings>
            <data>
                <param name="config">
                    <param name="component" xsi:type="string">Magento_Ui/js/grid/provider</param>
                    <param name="filterUrlParams" xsi:type="array">
                        <item name="status" xsi:type="string">status</item>
                    </param>
                </param>
            </data>
        </dataProvider>
    </dataSource>

    <listingToolbar name="listing_toolbar">
        <settings>
            <sticky>true</sticky>
        </settings>
        <massactions name="listing_massactions">
            <actionComponent name="delete">
                <settings>
                    <confirm>
                        <message translate="true">Are you sure you want to delete selected items?</message>
                        <title translate="true">Delete items</title>
                    </confirm>
                    <url path="newsletter/newsletter/massDelete"/>
                    <type>delete</type>
                </settings>
            </actionComponent>
            <actionComponent name="status">
                <settings>
                    <confirm>
                        <message translate="true">Are you sure you want to change status of selected items?</message>
                        <title translate="true">Change status</title>
                    </confirm>
                    <url path="newsletter/newsletter/massStatus"/>
                    <type>confirm</type>
                </settings>
            </actionComponent>
        </massactions>
        <paging name="listing_paging"/>
        <filters name="listing_filters"/>
        <columnsControls name="columns_controls"/>
    </listingToolbar>

    <columns name="newsletter_columns">
        <selectionsColumn name="ids">
            <settings>
                <indexField>entity_id</indexField>
            </settings>
        </selectionsColumn>
        <column name="entity_id" class="Magento\Ui\Component\Listing\Columns\Column\Numeric">
            <settings>
                <filter>textRange</filter>
                <label translate="true">ID</label>
                <sorting>desc</sorting>
            </settings>
        </column>
        <column name="email" class="Magento\Ui\Component\Listing\Columns\Column\Text">
            <settings>
                <filter>text</filter>
                <bodyTmpl>ui/grid/cells/html</bodyTmpl>
                <label translate="true">Email</label>
                <visible>true</visible>
            </settings>
        </column>
        <column name="first_name" class="Magento\Ui\Component\Listing\Columns\Column\Text">
            <settings>
                <filter>text</filter>
                <label translate="true">First Name</label>
            </settings>
        </column>
        <column name="last_name" class="Magento\Ui\Component\Listing\Columns\Column\Text">
            <settings>
                <filter>text</filter>
                <label translate="true">Last Name</label>
            </settings>
        </column>
        <column name="status" class="Magento\Ui\Component\Listing\Columns\Column\Select">
            <settings>
                <filter>select</filter>
                <dataType>select</dataType>
                <label translate="true">Status</label>
                <bodyTmpl>ui/grid/cells/html</bodyTmpl>
            </settings>
            <dataProvider name="status_options"/>
        </column>
        <column name="created_at" class="Magento\Ui\Component\Listing\Columns\Column\Date">
            <settings>
                <filter>dateRange</filter>
                <dataType>date</dataType>
                <label translate="true">Created At</label>
            </settings>
        </column>
        <actionsColumn name="actions" class="Vendor\Newsletter\Ui\Component\Listing\Column\NewsletterActions">
            <settings>
                <indexField>entity_id</indexField>
                <visible>true</visible>
            </settings>
        </actionsColumn>
    </columns>
</listing>

Actions Column Class

<?php
namespace Vendor\Newsletter\Ui\Component\Listing\Column;

use Magento\Ui\Component\Listing\Columns\Column;
use Magento\Framework\View\Element\UiComponent\ContextInterface;

class NewsletterActions extends Column
{
    public function __construct(
        ContextInterface $context,
        array $components = [],
        array $data = [],
        private \Magento\Backend\Helper\Url $urlHelper,
    ) {
        parent::__construct($context, $components, $data);
    }

    public function prepareDataSource(array $dataSource): array
    {
        if (isset($dataSource['data']['items'])) {
            foreach ($dataSource['data']['items'] as &$item) {
                if (isset($item['entity_id'])) {
                    $item[$this->getData('name')] = [
                        'edit' => [
                            'href' => $this->urlHelper->getUrl(
                                'newsletter/newsletter/edit',
                                ['id' => $item['entity_id']]
                            ),
                            'label' => __('Edit'),
                        ],
                        'delete' => [
                            'href' => $this->urlHelper->getUrl(
                                'newsletter/newsletter/delete',
                                ['id' => $item['entity_id']]
                            ),
                            'label' => __('Delete'),
                            'confirm' => [
                                'title' => __('Delete %1', $item['email']),
                                'message' => __('Are you sure you want to delete this item?'),
                            ],
                        ],
                    ];
                }
            }
        }
        return $dataSource;
    }
}

Form, Data Provider, and Controllers

Form Component

<!-- view/adminhtml/ui_component/newsletter_form.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<form xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
    <argument name="data" xsi:type="array">
        <item name="js_config" xsi:type="array">
            <item name="provider" xsi:type="string">newsletter_form.newsletter_form_data_source</item>
        </item>
    </argument>
    <settings>
        <deps>
            <dep>newsletter_form.newsletter_form_data_source</dep>
        </deps>
        <buttons>
            <button name="delete" class="Vendor\Newsletter\Block\Adminhtml\Newsletter\Edit\Form\Button\Delete"/>
            <button name="reset" class="Magento\Ui\Component\Form\Button\Generic\Reset"/>
            <button name="save" class="Magento\Ui\Component\Form\Button\Generic\Save"/>
        </buttons>
    </settings>
    <dataSource name="newsletter_form_data_source">
        <dataProvider class="Vendor\Newsletter\Model\DataProvider\NewsletterDataProvider" name="newsletter_form_data_source">
            <settings>
                <requestFieldName>id</requestFieldName>
            </settings>
        </dataProvider>
    </dataSource>
    <fieldset name="" sortOrder="20">
        <settings>
            <collapsible>true</collapsible>
        </settings>
        <field name="entity_id" formElement="hidden">
            <settings>
                <dataType>text</dataType>
                <dataScope>entity_id</dataScope>
            </settings>
        </field>
        <field name="email" formElement="input">
            <settings>
                <validation>
                    <rule name="required-entry" xsi:type="boolean">true</rule>
                    <rule name="validate-email" xsi:type="boolean">true</rule>
                </validation>
                <dataType>text</dataType>
                <dataScope>email</dataScope>
                <label translate="true">Email</label>
            </settings>
        </field>
        <field name="first_name" formElement="input">
            <settings>
                <dataType>text</dataType>
                <dataScope>first_name</dataScope>
                <label translate="true">First Name</label>
            </settings>
        </field>
        <field name="last_name" formElement="input">
            <settings>
                <dataType>text</dataType>
                <dataScope>last_name</dataScope>
                <label translate="true">Last Name</label>
            </settings>
        </field>
        <field name="status" formElement="select">
            <settings>
                <dataType>select</dataType>
                <dataScope>status</dataScope>
                <label translate="true">Status</label>
                <options>
                    <option name="1" xsi:type="array">
                        <item name="value" xsi:type="string">1</item>
                        <item name="label" xsi:type="string" translate="true">Active</item>
                    </option>
                    <option name="0" xsi:type="array">
                        <item name="value" xsi:type="string">0</item>
                        <item name="label" xsi:type="string" translate="true">Inactive</item>
                    </option>
                </options>
            </settings>
        </field>
    </fieldset>
</form>

Data Provider

<?php
namespace Vendor\Newsletter\Model\DataProvider;

use Magento\Ui\DataProvider\Mod\DataProvider\ModDataProvider;
use Vendor\Newsletter\Model\ResourceModel\Newsletter\CollectionFactory;

class NewsletterDataProvider extends ModDataProvider
{
    public function __construct(
        string $name,
        string $primaryFieldName,
        string $requestFieldName,
        CollectionFactory $collectionFactory,
        array $meta = [],
        array $data = [],
    ) {
        parent::__construct($name, $primaryFieldName, $requestFieldName, $meta, $data);
        $this->collection = $collectionFactory->create();
    }

    public function getData(): array
    {
        $items = $this->collection->getItems();
        $result = ['items' => [], 'totalRecords' => $this->collection->getSize()];

        foreach ($items as $item) {
            $result['items'][] = $item->getData();
        }

        return $result;
    }

    public function getMeta(): array
    {
        return parent::getMeta();
    }
}

Admin Controllers

<?php
namespace Vendor\Newsletter\Controller\Adminhtml\Newsletter;

use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\Registry;
use Vendor\Newsletter\Model\NewsletterFactory;

class Save extends Action
{
    protected $_isMenu = false;

    public function __construct(
        Context $context,
        private NewsletterFactory $newsletterFactory,
        private Registry $coreRegistry,
    ) {
        parent::__construct($context);
    }

    public function execute()
    {
        $data = $this->getRequest()->getPostValue();
        $resultRedirect = $this->resultRedirectFactory->create();

        if ($data) {
            $model = $this->newsletterFactory->create();
            $id = $this->getRequest()->getParam('entity_id');

            if ($id) {
                $model->load($id);
            }

            $model->setData($data);

            try {
                $model->save();
                $this->messageManager->addSuccessMessage(__('The newsletter subscriber has been saved.'));
                $this->coreRegistry->registry('vendor_newsletter', $model);
                $this->_getSession()->setFormData($data);

                if ($this->getRequest()->getParam('back')) {
                    $resultRedirect->setPath('newsletter/newsletter/edit', ['id' => $model->getId()]);
                    return $resultRedirect;
                }

                $resultRedirect->setPath('newsletter/newsletter/index');
                return $resultRedirect;
            } catch (\Exception $e) {
                $this->messageManager->addErrorMessage($e->getMessage());
                $this->_getSession()->setFormData($data);
            }
        }

        $resultRedirect->setPath('newsletter/newsletter/index');
        return $resultRedirect;
    }
}

Mass Action Controllers

<?php
namespace Vendor\Newsletter\Controller\Adminhtml\Newsletter;

use Magento\Backend\App\Action;
use Magento\Framework\Controller\Result\JsonFactory;

class MassDelete extends Action
{
    public function __construct(
        Action\Context $context,
        private JsonFactory $resultJsonFactory,
        private \Vendor\Newsletter\Model\ResourceModel\Newsletter\CollectionFactory $collectionFactory,
    ) {
        parent::__construct($context);
    }

    public function execute()
    {
        $selected = $this->getRequest()->getParam('selected');
        $resultRedirect = $this->resultRedirectFactory->create();

        if (empty($selected)) {
            $this->messageManager->addErrorMessage(__('No items selected.'));
            $resultRedirect->setPath('newsletter/newsletter/index');
            return $resultRedirect;
        }

        try {
            $collection = $this->collectionFactory->create()->addFieldToFilter('entity_id', ['in' => $selected]);
            $deletedCount = 0;

            foreach ($collection as $item) {
                $item->delete();
                $deletedCount++;
            }

            $this->messageManager->addSuccessMessage(
                __('A total of %1 record(s) have been deleted.', $deletedCount)
            );
        } catch (\Exception $e) {
            $this->messageManager->addErrorMessage($e->getMessage());
        }

        $resultRedirect->setPath('newsletter/newsletter/index');
        return $resultRedirect;
    }
}

ACL Configuration

<!-- etc/acl.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd">
    <acl>
        <resources>
            <resource id="Magento_Backend::admin">
                <resource id="Magento_Backend::stores">
                    <resource id="Magento_Backend::stores_settings">
                        <resource id="Magento_Config::config">
                            <resource id="Vendor_Newsletter::newsletter" title="Newsletter Management"/>
                        </resource>
                    </resource>
                </resource>
            </resource>
        </resources>
    </acl>
</config>

Admin Menu

<!-- etc/adminhtml.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Backend:etc/adminhtml.xsd">
    <menu>
        <add id="Vendor_Newsletter::newsletter"
             title="Newsletter Subscribers"
             module="Vendor_Newsletter"
             sortOrder="71"
             resource="Vendor_Newsletter::newsletter"/>
        <add id="Vendor_Newsletter::newsletter_subscribers"
             title="All Subscribers"
             module="Vendor_Newsletter"
             sortOrder="10"
             parent="Vendor_Newsletter::newsletter"
             action="newsletter/newsletter/index"
             resource="Vendor_Newsletter::newsletter"/>
    </menu>
</config>

Quiz

1. Where is the UI component grid XML defined for admin?

Question 1 options

2. What class provides data to admin forms?

Question 2 options

3. How do mass actions submit selected items?

Question 3 options

Flashcards

Question

Where is the admin grid UI component defined?

Answer

view/adminhtml/ui_component/ listing XML file

Question

What is a DataProvider?

Answer

Class that fetches data for UI component forms and grids

Question

How do you add mass actions to a grid?

Answer

Define actionComponent elements in listingToolbar > massactions

Question

Where is admin menu defined?

Answer

etc/adminhtml.xml under <menu> node

Question

What must resource model extend?

Answer

AbstractDb and implement _construct() with _init()

Revision Notes

Key Takeaways

  • 1. Admin CRUD modules follow standard structure: Model, ResourceModel, Collection
  • 2. UI components provide grid and form in adminhtml area
  • 3. DataProvider classes supply data to grids and forms
  • 4. Mass actions use actionComponent with selected[] parameter
  • 5. ACL controls access to admin menu items and actions

Interview Tips

  • Walk through creating a complete admin CRUD module step by step
  • Explain the relationship between UI components and DataProviders
  • Describe how mass actions work with controllers
  • Discuss ACL configuration for admin resources

Cheat Sheet

Admin CRUD Module:
  registration.php → Register module
  etc/module.xml → Module declaration
  etc/db_schema.xml → Database tables
  Model/ → Data model extends AbstractModel
  ResourceModel/ → DB operations extends AbstractDb
  Collection/ → Query builder extends AbstractCollection

UI Components:
  ui_component/listing.xml → Grid
  ui_component/form.xml → Edit form
  DataProvider → Supplies data to components

Controllers:
  Index → Grid view
  NewAction → Empty form
  Edit → Load entity, show form
  Save → Create/update entity
  Delete → Remove entity
  MassDelete/MassStatus → Bulk operations

ACL:
  etc/acl.xml → Define permissions
  etc/adminhtml.xml → Menu items with resource refs