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?
2. What class provides data to admin forms?
3. How do mass actions submit selected items?
Flashcards
Question
Where is the admin grid UI component defined?
Click to reveal answer
Answer
view/adminhtml/ui_component/ listing XML file
Question
What is a DataProvider?
Click to reveal answer
Answer
Class that fetches data for UI component forms and grids
Question
How do you add mass actions to a grid?
Click to reveal answer
Answer
Define actionComponent elements in listingToolbar > massactions
Question
Where is admin menu defined?
Click to reveal answer
Answer
etc/adminhtml.xml under <menu> node
Question
What must resource model extend?
Click to reveal answer
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