Admin Development

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Magento Open Source and Adobe Commerce 2.4.9 (general availability 2026-05-12) and checked on 2026-10-27. Every example was installed on Mage-OS 3.6.0, the Open Source distribution based on Magento 2.4.9, and exercised over HTTP as an administrator and as a restricted role, so the response codes, JSON and database rows below are observed results. The in-process PHP Admin extension model applies to Magento Open Source and to Adobe Commerce on-premises and on cloud infrastructure. Adobe Commerce as a Cloud Service (ACCS) accepts no PHP modules; Adobe lists the Admin UI SDK, which adds menus, pages and actions to the Admin from an App Builder application, among its extensibility tools, and the equivalent of this page for that edition is in Out-of-Process Extensibility. With Adobe IMS sign-in enabled the native two-factor authentication module is not used, a topic of Security and API Authentication.

The Admin is an ordinary Magento area (adminhtml) with its own router, its own layout and templates, and a security model of secret keys, form keys and an ACL (access control list) of resources. This page builds the Admin side of the Pocket Coffee shop: a "Pocket Coffee" menu with a grid and a form for roast batches (reference, origin, roast date, quantity in kilograms), a configuration section "Roast settings" with a freshness window, a badge label and an encrypted API token for the roaster’s system, and the ACL that decides who can see and change each part. It follows Routing, Controllers, Blocks, Layouts and Templates, which covers the storefront counterpart, and uses the table created with Declarative Schema and Patches.

The module at a glance

File Role

etc/module.xml, registration.php, composer.json

The module skeleton (see Module Anatomy); the sequence lists Magento_Backend, Magento_Config and Magento_Ui.

etc/db_schema.xml, etc/db_schema_whitelist.json

The table pocketcoffee_roast_batch.

Model/Batch.php, Model/ResourceModel/Batch.php, Model/ResourceModel/Batch/Collection.php

The model, resource model and collection of the table.

etc/acl.xml

The resource tree.

etc/adminhtml/menu.xml

The menu entries.

etc/adminhtml/routes.xml

The Admin route pocketcoffee_roast.

Controller/Adminhtml/Batch/*.php

The action classes: Index, Edit, NewAction, Save, Delete, MassDelete.

view/adminhtml/layout/*.xml

The two page layouts that place a UI component in the content area.

view/adminhtml/ui_component/*.xml

The listing and the form.

Ui/DataProvider/BatchForm.php, Ui/Component/Listing/Column/BatchActions.php, Block/Adminhtml/Batch/Edit/*Button.php

The form data provider, the row actions and the form buttons.

etc/adminhtml/system.xml, etc/config.xml, etc/di.xml

The configuration section, its defaults, and the sensitive and environment-specific declarations.

Model/Settings.php, Console/Command/ShowSettingsCommand.php

Typed access to the configuration and a command that prints it.

Admin routes, controllers and the secret key

An Admin route is declared in etc/adminhtml/routes.xml with router id="admin"; the file location puts it in the adminhtml area. The frontName becomes the first segment after the Admin URI, which is chosen at installation (admin_local on the test installation, from --backend-frontname; the installer generates a random value when it is omitted).

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <router id="admin">
        <route id="pocketcoffee_roast" frontName="pocketcoffee_roast">
            <module name="PocketCoffee_RoastAdmin"/>
        </route>
    </router>
</config>

An Admin controller lives in Controller/Adminhtml/<Controller>/<Action>.php, extends Magento\Backend\App\Action and implements an HTTP-method interface exactly as a storefront action does. The namespace is the access rule for the user type: the Adobe documentation states that a controller in Controller\Adminhtml is accessible only to administrators, and that the controller must override the ADMIN_RESOURCE constant to require a specific resource.

<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Controller\Adminhtml\Batch;

use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Backend\Model\View\Result\Page;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\PageFactory;

/**
 * GET {admin}/pocketcoffee_roast/batch/index: the listing page. The ACL resource below is checked by the
 * Admin authentication plugin before execute() runs.
 */
class Index extends Action implements HttpGetActionInterface
{
    public const ADMIN_RESOURCE = 'PocketCoffee_RoastAdmin::batches';

    public function __construct(Context $context, private readonly PageFactory $resultPageFactory)
    {
        parent::__construct($context);
    }

    public function execute(): Page
    {
        /** @var Page $page */
        $page = $this->resultPageFactory->create();
        $page->setActiveMenu('PocketCoffee_RoastAdmin::batches');
        $page->getConfig()->getTitle()->prepend(__('Roast batches'));

        return $page;
    }
}

Three protections apply to every Admin request, and all three were observed on the test installation:

  • Secret key. With "Add Secret Key to URLs" enabled (the default), each Admin URL carries /key/<hash>/ computed for that controller path and the session. A request with a wrong key is redirected to the dashboard with HTTP 302. Links built with UrlInterface::getUrl() or Magento\Backend\Model\UrlInterface include the key automatically; the keys of the test run were read from the menu, the grid JSON and the form configuration, which is how any script has to obtain them.

  • Form key. A POST without a valid form_key is redirected to the dashboard; the Admin writes the reason to var/log/debug.log as "Request validation failed …​ BackendValidator". A GET against a POST-only action answered 404 and logged that the URI "cannot be accessed with GET method".

  • ACL. The ADMIN_RESOURCE of the class (or Magento_Backend::admin when it is not overridden) is checked against the role of the logged-in administrator before execute() runs. A denied request returns HTTP 403 with the page "Sorry, you need permissions to view this content".

ACL: acl.xml

The ACL is a tree of resources. A role is a set of selected resources; the Admin shows the tree in System > Permissions > User Roles > Role Resources, and a resource appears there only if some module declares it in 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="PocketCoffee_RoastAdmin::root" title="Pocket Coffee" translate="title" sortOrder="80">
                    <resource id="PocketCoffee_RoastAdmin::batches" title="Roast batches" translate="title" sortOrder="10">
                        <resource id="PocketCoffee_RoastAdmin::batches_save" title="Save roast batches" translate="title" sortOrder="10"/>
                        <resource id="PocketCoffee_RoastAdmin::batches_delete" title="Delete roast batches" translate="title" sortOrder="20"/>
                    </resource>
                </resource>
                <resource id="Magento_Backend::stores">
                    <resource id="Magento_Backend::stores_settings">
                        <resource id="Magento_Config::config">
                            <resource id="PocketCoffee_RoastAdmin::config" title="Pocket Coffee roast settings" translate="title" sortOrder="80"/>
                        </resource>
                    </resource>
                </resource>
            </resource>
        </resources>
    </acl>
</config>

Each <resource> has an id in the form Vendor_Module::name, a title (shown in the role tree, with translate="title" to translate it), an optional sortOrder and an optional disabled attribute. The nesting is meaningful: selecting a child in the role editor implies the parents, so the tree should follow the screens a person navigates (root, then the grid, then the actions on it). The example separates view (batches) from save and delete, so a role can be limited to reading. The config resource is declared under the existing Magento_Backend::stores > Magento_Backend::stores_settings > Magento_Config::config branch so that it appears in the Configuration part of the role tree; the nested <resource> elements in the file only reference ancestors that other modules define, and the declarations are merged.

One ACL tree in acl.xml and the four places where its resources are enforced: menu, controllers, UI components and system configuration

The same resource id is used in four places, which the figure maps: the resource attribute of menu.xml entries, the ADMIN_RESOURCE constant of controllers, the aclResource of UI components, and the <resource> of a system.xml section. REST routes in webapi.xml use <resource ref="..."> in the same way (see REST and GraphQL APIs).

Testing the ACL with a restricted role

A role "Roast viewer" holding only Magento_Backend::admin, PocketCoffee_RoastAdmin::root and PocketCoffee_RoastAdmin::batches was created (a one-off script using the authorization role and rules models; the Admin role editor does the same) with the user roastviewer. Logging in as that user over HTTP gave:

Request Result Notes

Login

302 to the batches grid

The Admin redirects to the first menu item the role may open.

GET batch/index

200

The menu showed only Pocket Coffee  Roast batches.

mui/index/render with namespace=pocketcoffee_roastbatch_listing

200 with the JSON rows

The listing’s aclResource is PocketCoffee_RoastAdmin::batches, which the role holds.

GET batch/new

403

NewAction requires batches_save.

POST batch/massDelete

403

MassDelete requires batches_delete.

mui/index/render with namespace=customer_listing

403 {"error":"Forbidden","errorcode":403}

A core listing that declares an aclResource.

The last two rows are the point of the aclResource setting: a UI component can be rendered on its own through mui/index/render, without a controller, so a listing that exposes sensitive rows must declare the resource itself. The Adobe authorization guide states exactly this.

The menu: menu.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Backend:etc/menu.xsd">
    <menu>
        <add id="PocketCoffee_RoastAdmin::root" title="Pocket Coffee" translate="title" module="PocketCoffee_RoastAdmin" sortOrder="80" resource="PocketCoffee_RoastAdmin::root"/>
        <add id="PocketCoffee_RoastAdmin::batches" title="Roast batches" translate="title" module="PocketCoffee_RoastAdmin" sortOrder="10" parent="PocketCoffee_RoastAdmin::root" action="pocketcoffee_roast/batch/index" resource="PocketCoffee_RoastAdmin::batches"/>
        <add id="PocketCoffee_RoastAdmin::settings" title="Roast settings" translate="title" module="PocketCoffee_RoastAdmin" sortOrder="20" parent="PocketCoffee_RoastAdmin::root" action="adminhtml/system_config/edit/section/pocketcoffee_roast" resource="PocketCoffee_RoastAdmin::config"/>
    </menu>
</config>

An <add> element creates an entry. Its attributes:

Attribute Meaning

id

Unique id, conventionally the module name and a key; the id is also the argument of setActiveMenu() in a controller.

title, translate

The label, with translate="title" for translation.

module

The module that owns the entry and translates its title.

sortOrder

Position among siblings; lower numbers come first.

parent

The id of the parent entry; omitted for a top-level entry.

action

The route path frontName/controller/action, without the key. A section link uses adminhtml/system_config/edit/section/<section-id>, as the settings entry does.

resource

The ACL resource a role needs to see the entry. Without it the entry is visible to everyone who may log in.

dependsOnModule, dependsOnConfig

Show the entry only when another module is enabled or a configuration flag is set.

target, toolTip

Browser target and a tooltip for the entry.

<update id="..."> changes attributes of an entry that another module added, and <remove id="..."/> deletes an entry; both are resolved after all <add> elements are merged. The top-level entry of the example appeared on the dashboard HTML as the ids menu-pocketcoffee-roastadmin-root, ...-batches and ...-settings.

The Adobe placement guidelines advise against icons and marketing in the primary navigation, and put settings of a connector under Stores > Settings > Configuration. The new top-level menu here is justified by a feature with its own data screen, and the settings entry is a convenience link to the same configuration section that also appears under Stores. A pure connector would have the configuration section only.

Configuration: system.xml and config.xml

The Stores > Settings > Configuration screen is generated from the system.xml files of all modules. The file has four levels: tab (a left-hand grouping), section (a page), group (a collapsible block) and field.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
    <system>
        <tab id="pocketcoffee" translate="label" sortOrder="900">
            <label>Pocket Coffee</label>
        </tab>
        <section id="pocketcoffee_roast" translate="label" type="text" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
            <label>Roast settings</label>
            <tab>pocketcoffee</tab>
            <resource>PocketCoffee_RoastAdmin::config</resource>
            <group id="general" translate="label" type="text" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
                <label>General</label>
                <field id="enabled" translate="label comment" type="select" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="0" canRestore="1">
                    <label>Show roast badge</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                    <comment>Show the roast date on product pages.</comment>
                </field>
                <field id="freshness_days" translate="label comment tooltip" type="text" sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="0" canRestore="1">
                    <label>Freshness window (days)</label>
                    <validate>validate-digits validate-greater-than-zero</validate>
                    <backend_model>PocketCoffee\RoastAdmin\Model\Config\Backend\PositiveInteger</backend_model>
                    <comment>Beans older than this are shown as "past peak".</comment>
                    <tooltip>The window starts on the roast date.</tooltip>
                    <depends>
                        <field id="enabled">1</field>
                    </depends>
                </field>
                <field id="badge_label" translate="label" type="text" sortOrder="30" showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Badge label</label>
                    <depends>
                        <field id="enabled">1</field>
                    </depends>
                </field>
            </group>
            <group id="roaster_api" translate="label" type="text" sortOrder="20" showInDefault="1" showInWebsite="0" showInStore="0">
                <label>Roaster system</label>
                <field id="api_url" translate="label" type="text" sortOrder="10" showInDefault="1">
                    <label>API URL</label>
                    <validate>validate-url</validate>
                </field>
                <field id="api_token" translate="label comment" type="obscure" sortOrder="20" showInDefault="1">
                    <label>API token</label>
                    <backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model>
                    <comment>Stored encrypted. Never exported by app:config:dump.</comment>
                </field>
            </group>
        </section>
    </system>
</config>

The attributes and children used above, and the others of the schema system_file.xsd that matter:

Element or attribute Effect

showInDefault, showInWebsite, showInStore

The scopes at which the field is editable; a field never shown at a scope cannot be overridden there, and a value read at that scope falls back to the default.

canRestore="1"

Shows "Use system value" at the default scope so the value can go back to the config.xml default.

type

The widget: text, textarea, select, multiselect, multiline, password, obscure (a masked field that keeps the stored value when left blank), time, label, button, image and file.

<source_model>

The class that returns the options of a select or multiselect; Magento\Config\Model\Config\Source\Yesno gives Yes and No.

<backend_model>

The class that processes the value on save and load (extends Magento\Framework\App\Config\Value); Magento\Config\Model\Config\Backend\Encrypted encrypts the value.

<validate>

Client-side validation classes (validate-digits, validate-url, validate-greater-than-zero, …​). Server-side rules need a backend model.

<comment>, <tooltip>

Help text under the field, and a tooltip.

<depends><field id="enabled">1</field></depends>

Shows the field only when another field of the group has a value.

<config_path>

Stores the value at a path other than section/group/field.

<resource> (on a section)

The ACL resource that gates the whole section.

<frontend_model>

A block class that renders the field (custom widgets and buttons).

<if_module_enabled>

Shows the field only if the named module is enabled.

<hint>, <frontend_class>, <can_be_empty>

A short hint, a CSS class on the field, and permission to save an empty multiselect.

<tab> (in a section)

The tab id; a new tab is declared with <tab id="..."> and a <label>.

The defaults live in etc/config.xml, under <default> (and optionally <websites> and <stores> by code), at the same path as the field:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
    <default>
        <pocketcoffee_roast>
            <general>
                <enabled>1</enabled>
                <freshness_days>21</freshness_days>
                <badge_label>Roasted on</badge_label>
            </general>
            <roaster_api>
                <api_url>https://roaster.example.test/api</api_url>
            </roaster_api>
        </pocketcoffee_roast>
    </default>
</config>

Reading values in code

<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Model;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Encryption\EncryptorInterface;
use Magento\Store\Model\ScopeInterface;

/**
 * Typed access to the pocketcoffee_roast/* configuration. All reads go through ScopeConfigInterface, so values
 * come from config.xml, config.php, env.php, core_config_data and environment variables in that order of precedence.
 */
class Settings
{
    private const PATH_ENABLED = 'pocketcoffee_roast/general/enabled';
    private const PATH_FRESHNESS = 'pocketcoffee_roast/general/freshness_days';
    private const PATH_LABEL = 'pocketcoffee_roast/general/badge_label';
    private const PATH_URL = 'pocketcoffee_roast/roaster_api/api_url';
    private const PATH_TOKEN = 'pocketcoffee_roast/roaster_api/api_token';

    public function __construct(
        private readonly ScopeConfigInterface $scopeConfig,
        private readonly EncryptorInterface $encryptor
    ) {
    }

    public function isEnabled(?int $storeId = null): bool
    {
        return $this->scopeConfig->isSetFlag(self::PATH_ENABLED, ScopeInterface::SCOPE_STORE, $storeId);
    }

    public function getFreshnessDays(?int $websiteId = null): int
    {
        return (int) $this->scopeConfig->getValue(self::PATH_FRESHNESS, ScopeInterface::SCOPE_WEBSITE, $websiteId);
    }

    public function getBadgeLabel(?int $storeId = null): string
    {
        return (string) $this->scopeConfig->getValue(self::PATH_LABEL, ScopeInterface::SCOPE_STORE, $storeId);
    }

    public function getApiUrl(): string
    {
        return (string) $this->scopeConfig->getValue(self::PATH_URL);
    }

    /**
     * The stored value is encrypted by the Encrypted backend model; decrypt it only where the call is made.
     */
    public function getApiToken(): string
    {
        $stored = (string) $this->scopeConfig->getValue(self::PATH_TOKEN);

        return $stored === '' ? '' : $this->encryptor->decrypt($stored);
    }
}

The reads go through ScopeConfigInterface, with the scope type and the scope id: SCOPE_STORE for the store view, SCOPE_WEBSITE for a website. isSetFlag() returns a boolean for Yes/No fields. The token is stored encrypted, so the class decrypts it with EncryptorInterface at the point of use and never logs it. The command below prints the values; the token is shown only as a length so that the output is safe to share.

<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Console\Command;

use PocketCoffee\RoastAdmin\Model\Settings;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class ShowSettingsCommand extends Command
{
    public function __construct(private readonly Settings $settings, ?string $name = null)
    {
        parent::__construct($name);
    }

    protected function configure(): void
    {
        $this->setName('pocketcoffee:roast:settings')
            ->setDescription('Print the roast settings; the token is shown only as a length');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $output->writeln('enabled: ' . ($this->settings->isEnabled() ? 'yes' : 'no'));
        $output->writeln('freshness days: ' . $this->settings->getFreshnessDays());
        $output->writeln('badge label: ' . $this->settings->getBadgeLabel());
        $output->writeln('api url: ' . $this->settings->getApiUrl());
        $output->writeln('api token length: ' . strlen($this->settings->getApiToken()));

        return Command::SUCCESS;
    }
}

On the test installation the command printed, after the values below were saved (the token length is that of a placeholder):

$ bin/magento pocketcoffee:roast:settings
enabled: yes
freshness days: 14
badge label: Roasted on
api url: https://roaster.example.test/api
api token length: 21

Saving values: the Admin and the CLI

The configuration screen and bin/magento config:set both pass the value through the field’s backend model. Several behaviours were checked:

  • The Admin form posted with groups[general][fields][freshness_days][value]=abc was accepted and stored: the <validate> element is evaluated only by the browser, so a script, a scripted import or a curl call bypasses it. The server-side guard is the backend model in the next listing; with it in place bin/magento config:set pocketcoffee_roast/general/freshness_days abc failed with "The freshness window must be a whole number of days greater than zero." and config:set ... 14 printed "Value was saved."

  • config:set --scope=websites --scope-code=base pocketcoffee_roast/general/freshness_days 10 stored a website-scope row, and config:show returned 14 at the default scope and 10 for the website. The field is shown at the website scope in the Admin as well, because of showInWebsite="1".

  • The API token posted through the Admin was stored in core_config_data as an opaque string beginning 0:3: (a key-version and cipher prefix followed by the ciphertext), never as the plain value, and config:show pocketcoffee_roast/roaster_api/api_token printed ****** because the path is declared sensitive.

<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Model\Config\Backend;

use Magento\Framework\App\Config\Value;
use Magento\Framework\Exception\LocalizedException;

/**
 * Server-side validation of the freshness window. The validate element of system.xml only runs in the browser.
 */
class PositiveInteger extends Value
{
    public function beforeSave(): self
    {
        $value = trim((string) $this->getValue());
        if (!ctype_digit($value) || (int) $value < 1) {
            throw new LocalizedException(
                __('The freshness window must be a whole number of days greater than zero.')
            );
        }
        $this->setValue($value);

        return parent::beforeSave();
    }
}

Sensitive and environment-specific values

Two lists in etc/di.xml classify configuration paths for the configuration-management commands:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <!-- Configuration values that must not be dumped to config.php, and values that differ per environment. -->
    <type name="Magento\Config\Model\Config\TypePool">
        <arguments>
            <argument name="sensitive" xsi:type="array">
                <item name="pocketcoffee_roast/roaster_api/api_token" xsi:type="string">1</item>
            </argument>
            <argument name="environment" xsi:type="array">
                <item name="pocketcoffee_roast/roaster_api/api_url" xsi:type="string">1</item>
            </argument>
        </arguments>
    </type>

    <type name="Magento\Framework\Console\CommandListInterface">
        <arguments>
            <argument name="commands" xsi:type="array">
                <item name="pocketcoffee_roast_settings" xsi:type="object">PocketCoffee\RoastAdmin\Console\Command\ShowSettingsCommand</item>
            </argument>
        </arguments>
    </type>

    <!-- The listing's data source: a collection over the table, exposed to the UI component framework. -->
    <virtualType name="PocketCoffee\RoastAdmin\Model\ResourceModel\Batch\Grid\Collection"
                 type="Magento\Framework\View\Element\UiComponent\DataProvider\SearchResult">
        <arguments>
            <argument name="mainTable" xsi:type="string">pocketcoffee_roast_batch</argument>
            <argument name="resourceModel" xsi:type="string">PocketCoffee\RoastAdmin\Model\ResourceModel\Batch</argument>
        </arguments>
    </virtualType>
    <type name="Magento\Framework\View\Element\UiComponent\DataProvider\CollectionFactory">
        <arguments>
            <argument name="collections" xsi:type="array">
                <item name="pocketcoffee_roastbatch_listing_data_source" xsi:type="string">PocketCoffee\RoastAdmin\Model\ResourceModel\Batch\Grid\Collection</item>
            </argument>
        </arguments>
    </type>
</config>
  • A sensitive path (keys, passwords, tokens, personal data) is excluded when the configuration is exported, so that it is not written into app/etc/config.php, which is committed to version control. The exported file instead lists the environment variable (here CONFIG__DEFAULT__POCKETCOFFEE_ROAST__ROASTER_API__API_TOKEN) that supplies the value in each environment. Adobe states that such values belong in app/etc/env.php, which is not committed.

  • An environment-specific path (URLs, hostnames, ports, modes, e-mail recipients) differs per system and is likewise kept out of the shared config.php.

Both are plain TypePool arguments, keyed by the path, with the value 1. The declarations were validated against the object manager schema and loaded; the export itself (bin/magento app:config:dump) rewrites app/etc/config.php and app/etc/env.php of the installation and was not run on the shared test store. The related options config:set --lock-env and --lock-config write a value into env.php or config.php and make the field read-only in the Admin; they were not run for the same reason.

An environment variable named CONFIG__<SCOPE>__<PATH>, with the slashes of the path replaced by double underscores and all upper case, overrides the stored value. On the test installation the variable CONFIG__DEFAULT__POCKETCOFFEE_ROAST__GENERAL__BADGE_LABEL set to "Roasted the" changed the badge label from the config.xml default "Roasted on" to "Roasted the" in the command output. The effective configuration is cached, so the override showed only after cache:clean config ran with the variable set; the same cached value kept being served after the variable was removed, until the configuration cache was cleaned again. Treat a change of an environment variable as a deployment step that includes a configuration cache flush.

The data: table, model and collection

The grid reads the table of the declarative schema. The generated whitelist file is committed with the module; the command that produced it was bin/magento setup:db-declaration:generate-whitelist --module-name=PocketCoffee_RoastAdmin.

<?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="pocketcoffee_roast_batch" resource="default" engine="innodb" comment="Pocket Coffee roast batches">
        <column xsi:type="int" name="batch_id" unsigned="true" nullable="false" identity="true" comment="Batch ID"/>
        <column xsi:type="varchar" name="reference" nullable="false" length="32" comment="Batch reference"/>
        <column xsi:type="varchar" name="origin" nullable="false" length="32" comment="Origin"/>
        <column xsi:type="date" name="roast_date" nullable="false" comment="Roast date"/>
        <column xsi:type="decimal" name="quantity_kg" scale="2" precision="8" unsigned="true" nullable="false" default="0" comment="Quantity in kg"/>
        <column xsi:type="smallint" name="is_active" unsigned="true" nullable="false" default="1" comment="Is active"/>
        <column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP" on_update="false" comment="Created at"/>
        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="batch_id"/>
        </constraint>
        <constraint xsi:type="unique" referenceId="POCKETCOFFEE_ROAST_BATCH_REFERENCE">
            <column name="reference"/>
        </constraint>
        <index referenceId="POCKETCOFFEE_ROAST_BATCH_ROAST_DATE" indexType="btree">
            <column name="roast_date"/>
        </index>
    </table>
</schema>
{
    "pocketcoffee_roast_batch": {
        "column": {
            "batch_id": true,
            "reference": true,
            "origin": true,
            "roast_date": true,
            "quantity_kg": true,
            "is_active": true,
            "created_at": true
        },
        "index": {
            "POCKETCOFFEE_ROAST_BATCH_ROAST_DATE": true
        },
        "constraint": {
            "PRIMARY": true,
            "POCKETCOFFEE_ROAST_BATCH_REFERENCE": true
        }
    }
}

setup:upgrade created the table with the batch_id primary key, the unique key on reference and the index on roast_date.

<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Model;

use Magento\Framework\Model\AbstractModel;
use PocketCoffee\RoastAdmin\Model\ResourceModel\Batch as BatchResource;

class Batch extends AbstractModel
{
    protected $_idFieldName = 'batch_id';

    protected function _construct(): void
    {
        $this->_init(BatchResource::class);
    }
}
<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Model\ResourceModel;

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

class Batch extends AbstractDb
{
    protected function _construct(): void
    {
        $this->_init('pocketcoffee_roast_batch', 'batch_id');
    }
}
<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Model\ResourceModel\Batch;

use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
use PocketCoffee\RoastAdmin\Model\Batch;
use PocketCoffee\RoastAdmin\Model\ResourceModel\Batch as BatchResource;

class Collection extends AbstractCollection
{
    protected $_idFieldName = 'batch_id';

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

The example uses the model, resource model and collection triple directly, which is enough for an internal Admin screen. A module whose data must be reachable from other modules or the API should expose a repository and data interfaces instead (see Service Contracts and Repositories); the controllers would then inject the repository rather than the resource model. The save() and load() methods of AbstractModel are deprecated, which is why the controllers call $this->resource->save() and ->load().

The listing: UI component grid

The Admin grid is a UI component of type listing. The layout places it, the XML describes it, and the data comes from a data provider named in the XML.

<?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">
    <body>
        <referenceContainer name="content">
            <uiComponent name="pocketcoffee_roastbatch_listing"/>
        </referenceContainer>
    </body>
</page>

The layout file is named after the route id, controller and action (pocketcoffee_roast_batch_index); the uiComponent element renders the component with the same name as the file view/adminhtml/ui_component/pocketcoffee_roastbatch_listing.xml.

<?xml version="1.0"?>
<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="provider" xsi:type="string">pocketcoffee_roastbatch_listing.pocketcoffee_roastbatch_listing_data_source</item>
        </item>
    </argument>
    <settings>
        <buttons>
            <button name="add">
                <url path="*/*/new"/>
                <class>primary</class>
                <label translate="true">Add roast batch</label>
            </button>
        </buttons>
        <spinner>pocketcoffee_roastbatch_columns</spinner>
        <deps>
            <dep>pocketcoffee_roastbatch_listing.pocketcoffee_roastbatch_listing_data_source</dep>
        </deps>
    </settings>
    <dataSource name="pocketcoffee_roastbatch_listing_data_source" component="Magento_Ui/js/grid/provider">
        <settings>
            <storageConfig>
                <param name="indexField" xsi:type="string">batch_id</param>
            </storageConfig>
            <updateUrl path="mui/index/render"/>
        </settings>
        <aclResource>PocketCoffee_RoastAdmin::batches</aclResource>
        <dataProvider class="Magento\Framework\View\Element\UiComponent\DataProvider\DataProvider" name="pocketcoffee_roastbatch_listing_data_source">
            <settings>
                <requestFieldName>id</requestFieldName>
                <primaryFieldName>batch_id</primaryFieldName>
            </settings>
        </dataProvider>
    </dataSource>
    <listingToolbar name="listing_top">
        <settings>
            <sticky>true</sticky>
        </settings>
        <bookmark name="bookmarks"/>
        <columnsControls name="columns_controls"/>
        <filters name="listing_filters"/>
        <massaction name="listing_massaction">
            <action name="delete">
                <settings>
                    <confirm>
                        <message translate="true">Delete the selected roast batches?</message>
                        <title translate="true">Delete batches</title>
                    </confirm>
                    <url path="pocketcoffee_roast/batch/massDelete"/>
                    <type>delete</type>
                    <label translate="true">Delete</label>
                </settings>
            </action>
        </massaction>
        <paging name="listing_paging"/>
    </listingToolbar>
    <columns name="pocketcoffee_roastbatch_columns">
        <selectionsColumn name="ids">
            <settings>
                <indexField>batch_id</indexField>
            </settings>
        </selectionsColumn>
        <column name="batch_id">
            <settings>
                <filter>textRange</filter>
                <label translate="true">ID</label>
                <sorting>asc</sorting>
            </settings>
        </column>
        <column name="reference">
            <settings>
                <filter>text</filter>
                <label translate="true">Reference</label>
            </settings>
        </column>
        <column name="origin" component="Magento_Ui/js/grid/columns/select">
            <settings>
                <options class="PocketCoffee\RoastAdmin\Model\Source\Origin"/>
                <filter>select</filter>
                <dataType>select</dataType>
                <label translate="true">Origin</label>
            </settings>
        </column>
        <column name="roast_date" class="Magento\Ui\Component\Listing\Columns\Date" component="Magento_Ui/js/grid/columns/date">
            <settings>
                <filter>dateRange</filter>
                <dataType>date</dataType>
                <label translate="true">Roast date</label>
            </settings>
        </column>
        <column name="quantity_kg">
            <settings>
                <filter>textRange</filter>
                <label translate="true">Quantity (kg)</label>
            </settings>
        </column>
        <column name="is_active" component="Magento_Ui/js/grid/columns/select">
            <settings>
                <options class="Magento\Config\Model\Config\Source\Yesno"/>
                <filter>select</filter>
                <dataType>select</dataType>
                <label translate="true">Active</label>
            </settings>
        </column>
        <actionsColumn name="actions" class="PocketCoffee\RoastAdmin\Ui\Component\Listing\Column\BatchActions">
            <settings>
                <indexField>batch_id</indexField>
            </settings>
        </actionsColumn>
    </columns>
</listing>

How the parts fit together:

  • Naming. The data source id (pocketcoffee_roastbatch_listing_data_source) appears in the provider, in the deps, in the dataSource and dataProvider names, and as the key in di.xml. A mismatch in any of them typically leaves the grid on its loading spinner without an error message.

  • Data source. The component Magento_Ui/js/grid/provider requests rows from mui/index/render (the updateUrl) with the current search, filters, sorting and paging. On the server Magento\Framework\View\Element\UiComponent\DataProvider\DataProvider turns them into collection filters, using the collection registered for its name in DataProvider\CollectionFactory. The di.xml above registers a virtual type of SearchResult (a collection bound to the table pocketcoffee_roast_batch and the resource model) under the key pocketcoffee_roastbatch_listing_data_source. This is the standard way to expose a custom table without writing a data provider class.

  • Access. <aclResource> on the data source is checked by mui/index/render before the data provider runs (see the ACL section). The menu and controller checks do not cover this entry point.

  • Toolbar. bookmark stores the user’s saved views (in ui_bookmark), columnsControls shows or hides columns, filters renders a filter control per column, paging adds the pager, and massaction defines bulk actions; its action elements name a URL (pocketcoffee_roast/batch/massDelete), a confirmation and a type. The user’s selection is posted as selected[] (or excluded[] with a filter, for "select all"), with the namespace.

  • Columns. Each column names a database field. filter chooses the filter widget (text, textRange, select, dateRange), options class supplies the options of a select column, dataType the formatting, component the Knockout component and sorting the default sort. selectionsColumn renders the checkboxes; actionsColumn renders the row actions from the class below.

<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Ui\Component\Listing\Column;

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

/**
 * Adds the Edit and Delete links to every row of the listing.
 */
class BatchActions extends Column
{
    public function __construct(
        ContextInterface $context,
        UiComponentFactory $uiComponentFactory,
        private readonly UrlInterface $urlBuilder,
        array $components = [],
        array $data = []
    ) {
        parent::__construct($context, $uiComponentFactory, $components, $data);
    }

    public function prepareDataSource(array $dataSource): array
    {
        if (!isset($dataSource['data']['items'])) {
            return $dataSource;
        }

        // By reference: the row arrays are changed in place.
        foreach ($dataSource['data']['items'] as &$item) {
            $id = $item['batch_id'];
            $item[$this->getData('name')] = [
                'edit' => [
                    'href' => $this->urlBuilder->getUrl('pocketcoffee_roast/batch/edit', ['id' => $id]),
                    'label' => __('Edit'),
                ],
                'delete' => [
                    'href' => $this->urlBuilder->getUrl('pocketcoffee_roast/batch/delete', ['id' => $id]),
                    'label' => __('Delete'),
                    'confirm' => [
                        'title' => __('Delete %1', $item['reference']),
                        'message' => __('Are you sure you want to delete the batch %1?', $item['reference']),
                    ],
                    'post' => true,
                ],
            ];
        }

        return $dataSource;
    }
}
<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Model\Source;

use Magento\Framework\Data\OptionSourceInterface;

/**
 * Options of the origin select, shared by the grid filter and the form field.
 */
class Origin implements OptionSourceInterface
{
    public function toOptionArray(): array
    {
        return [
            ['value' => 'ETH', 'label' => __('Ethiopia Yirgacheffe')],
            ['value' => 'COL', 'label' => __('Colombia Huila')],
            ['value' => 'BRA', 'label' => __('Brazil Cerrado')],
        ];
    }
}

The first version of BatchActions iterated foreach ($dataSource['data']['items'] ?? [] as &$item). The coalescing operator produces a temporary array, so the changes made by reference were lost and the rows of the JSON response had no actions key (the grid showed no Edit and Delete links) without any error. The isset check followed by a by-reference loop over the real array, as listed above, produced the links. The row action delete carries "post": true and a confirm block, which makes the grid send a POST with the form key after the dialog; the Delete controller accepts only POST.

Querying the listing as the grid does

The grid’s own request can be issued directly, which is also the quickest way to test a data provider. The URL of mui/index/render carries the secret key; the JSON answer follows the filters:

curl -s -b admin.cookies -G "<render-url-with-key>" \
  -H "X-Requested-With: XMLHttpRequest" -H "Accept: application/json" \
  --data-urlencode "namespace=pocketcoffee_roastbatch_listing" \
  --data-urlencode "paging[pageSize]=20" --data-urlencode "paging[current]=1" \
  --data-urlencode "filters[origin]=COL"

With two batches in the table (PC-2026-10-A from Ethiopia with 12.5 kg and PC-2026-10-B from Colombia with 8 kg) the filters answered as follows:

Parameters Rows returned

none

2 (A and B)

filters[origin]=COL

1 (B)

filters[reference]=10-B

1 (B, a LIKE filter from the text filter)

filters[quantity_kg][from]=10

1 (A)

sorting[field]=reference, sorting[direction]=desc

2 (B, then A)

A search= keyword had no effect: the sample listing has no filterSearch element and the table no fulltext index, so there is no keyword box. Adding one needs both.

The form: UI component form

<?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">
    <body>
        <referenceContainer name="content">
            <uiComponent name="pocketcoffee_roastbatch_form"/>
        </referenceContainer>
    </body>
</page>
<?xml version="1.0"?>
<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">pocketcoffee_roastbatch_form.pocketcoffee_roastbatch_form_data_source</item>
        </item>
        <item name="template" xsi:type="string">templates/form/collapsible</item>
    </argument>
    <settings>
        <buttons>
            <button name="back" class="PocketCoffee\RoastAdmin\Block\Adminhtml\Batch\Edit\BackButton"/>
            <button name="delete" class="PocketCoffee\RoastAdmin\Block\Adminhtml\Batch\Edit\DeleteButton"/>
            <button name="save" class="PocketCoffee\RoastAdmin\Block\Adminhtml\Batch\Edit\SaveButton"/>
        </buttons>
        <namespace>pocketcoffee_roastbatch_form</namespace>
        <dataScope>data</dataScope>
        <deps>
            <dep>pocketcoffee_roastbatch_form.pocketcoffee_roastbatch_form_data_source</dep>
        </deps>
    </settings>
    <dataSource name="pocketcoffee_roastbatch_form_data_source">
        <settings>
            <submitUrl path="pocketcoffee_roast/batch/save"/>
        </settings>
        <aclResource>PocketCoffee_RoastAdmin::batches</aclResource>
        <dataProvider class="PocketCoffee\RoastAdmin\Ui\DataProvider\BatchForm" name="pocketcoffee_roastbatch_form_data_source">
            <settings>
                <requestFieldName>id</requestFieldName>
                <primaryFieldName>batch_id</primaryFieldName>
            </settings>
        </dataProvider>
    </dataSource>
    <fieldset name="general">
        <settings>
            <label translate="true">Roast batch</label>
        </settings>
        <field name="batch_id" formElement="hidden">
            <argument name="data" xsi:type="array">
                <item name="config" xsi:type="array">
                    <item name="source" xsi:type="string">batch</item>
                </item>
            </argument>
            <settings>
                <dataType>text</dataType>
            </settings>
        </field>
        <field name="reference" formElement="input" sortOrder="10">
            <argument name="data" xsi:type="array">
                <item name="config" xsi:type="array">
                    <item name="source" xsi:type="string">batch</item>
                </item>
            </argument>
            <settings>
                <dataType>text</dataType>
                <label translate="true">Reference</label>
                <validation>
                    <rule name="required-entry" xsi:type="boolean">true</rule>
                    <rule name="max_text_length" xsi:type="number">32</rule>
                </validation>
            </settings>
        </field>
        <field name="origin" formElement="select" sortOrder="20">
            <argument name="data" xsi:type="array">
                <item name="config" xsi:type="array">
                    <item name="source" xsi:type="string">batch</item>
                </item>
            </argument>
            <settings>
                <dataType>text</dataType>
                <label translate="true">Origin</label>
                <validation>
                    <rule name="required-entry" xsi:type="boolean">true</rule>
                </validation>
            </settings>
            <formElements>
                <select>
                    <settings>
                        <options class="PocketCoffee\RoastAdmin\Model\Source\Origin"/>
                    </settings>
                </select>
            </formElements>
        </field>
        <field name="roast_date" formElement="date" sortOrder="30">
            <argument name="data" xsi:type="array">
                <item name="config" xsi:type="array">
                    <item name="source" xsi:type="string">batch</item>
                </item>
            </argument>
            <settings>
                <dataType>text</dataType>
                <label translate="true">Roast date</label>
                <validation>
                    <rule name="required-entry" xsi:type="boolean">true</rule>
                </validation>
            </settings>
        </field>
        <field name="quantity_kg" formElement="input" sortOrder="40">
            <argument name="data" xsi:type="array">
                <item name="config" xsi:type="array">
                    <item name="source" xsi:type="string">batch</item>
                </item>
            </argument>
            <settings>
                <dataType>text</dataType>
                <label translate="true">Quantity (kg)</label>
                <validation>
                    <rule name="validate-number" xsi:type="boolean">true</rule>
                    <rule name="validate-greater-than-zero" xsi:type="boolean">true</rule>
                </validation>
            </settings>
        </field>
        <field name="is_active" formElement="checkbox" sortOrder="50">
            <argument name="data" xsi:type="array">
                <item name="config" xsi:type="array">
                    <item name="source" xsi:type="string">batch</item>
                    <item name="default" xsi:type="number">1</item>
                </item>
            </argument>
            <settings>
                <dataType>boolean</dataType>
                <label translate="true">Active</label>
            </settings>
            <formElements>
                <checkbox>
                    <settings>
                        <valueMap>
                            <map name="false" xsi:type="number">0</map>
                            <map name="true" xsi:type="number">1</map>
                        </valueMap>
                        <prefer>toggle</prefer>
                    </settings>
                </checkbox>
            </formElements>
        </field>
    </fieldset>
</form>
<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Ui\DataProvider;

use Magento\Ui\DataProvider\AbstractDataProvider;
use PocketCoffee\RoastAdmin\Model\ResourceModel\Batch\CollectionFactory;

/**
 * Supplies the values of the edit form: the framework asks for getData() keyed by the primary field.
 */
class BatchForm extends AbstractDataProvider
{
    public function __construct(
        string $name,
        string $primaryFieldName,
        string $requestFieldName,
        CollectionFactory $collectionFactory,
        array $meta = [],
        array $data = []
    ) {
        $this->collection = $collectionFactory->create();
        parent::__construct($name, $primaryFieldName, $requestFieldName, $meta, $data);
    }

    public function getData(): array
    {
        $result = [];
        foreach ($this->collection->getItems() as $batch) {
            $result[$batch->getId()] = $batch->getData();
        }

        return $result;
    }
}

The form XML declares, in order: the data source (the provider class, the submitUrl and the request field id), the buttons (each is a class implementing ButtonProviderInterface that returns an array), and a fieldset with the fields. A field has a formElement (input, select, date, checkbox, hidden, textarea, wysiwyg, fileUploader and others), a dataType, a label, validation rules (required-entry, validate-number, validate-greater-than-zero, max_text_length; these are client-side, like <validate> in system.xml) and an optional element block (<formElements>) for options and checkbox value maps.

The data provider returns the values of the record named by id in the request, keyed by the primary field; the framework extracts the matching record, and the response contained the values in the page configuration, for example "data":{"batch_id":"1","reference":"PC-2026-10-A","origin":"ETH","roast_date":"2026-10-20","quantity_kg":"12.50","is_active":"1",...} with the submit_url of the save action. The four button classes follow the same pattern:

<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Block\Adminhtml\Batch\Edit;

use Magento\Framework\UrlInterface;
use Magento\Framework\App\RequestInterface;

class GenericButton
{
    public function __construct(
        protected readonly UrlInterface $urlBuilder,
        protected readonly RequestInterface $request
    ) {
    }

    public function getBatchId(): ?int
    {
        $id = (int) $this->request->getParam('id');

        return $id ?: null;
    }

    public function getUrl(string $route = '', array $params = []): string
    {
        return $this->urlBuilder->getUrl($route, $params);
    }
}
<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Block\Adminhtml\Batch\Edit;

use Magento\Framework\View\Element\UiComponent\Control\ButtonProviderInterface;

class SaveButton extends GenericButton implements ButtonProviderInterface
{
    public function getButtonData(): array
    {
        return [
            'label' => __('Save batch'),
            'class' => 'save primary',
            'data_attribute' => [
                'mage-init' => ['buttonAdapter' => ['actions' => [['targetName' => 'pocketcoffee_roastbatch_form.pocketcoffee_roastbatch_form', 'actionName' => 'save', 'params' => [false]]]]],
            ],
            'sort_order' => 30,
        ];
    }
}
<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Block\Adminhtml\Batch\Edit;

use Magento\Framework\View\Element\UiComponent\Control\ButtonProviderInterface;

class DeleteButton extends GenericButton implements ButtonProviderInterface
{
    public function getButtonData(): array
    {
        if (!$this->getBatchId()) {
            return [];
        }

        return [
            'label' => __('Delete'),
            'class' => 'delete',
            'on_click' => 'deleteConfirm(\'' . __('Are you sure you want to delete this batch?') . '\', \''
                . $this->getUrl('*/*/delete', ['id' => $this->getBatchId()]) . '\', {data: {}})',
            'sort_order' => 20,
        ];
    }
}
<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Block\Adminhtml\Batch\Edit;

use Magento\Framework\View\Element\UiComponent\Control\ButtonProviderInterface;

class BackButton extends GenericButton implements ButtonProviderInterface
{
    public function getButtonData(): array
    {
        return [
            'label' => __('Back'),
            'on_click' => sprintf("location.href = '%s';", $this->getUrl('*/*/')),
            'class' => 'back',
            'sort_order' => 10,
        ];
    }
}

The action classes

<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Controller\Adminhtml\Batch;

use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Backend\Model\View\Result\Page;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\PageFactory;

/**
 * GET {admin}/pocketcoffee_roast/batch/edit/id/3, also used for "new" when there is no id.
 */
class Edit extends Action implements HttpGetActionInterface
{
    public const ADMIN_RESOURCE = 'PocketCoffee_RoastAdmin::batches';

    public function __construct(Context $context, private readonly PageFactory $resultPageFactory)
    {
        parent::__construct($context);
    }

    public function execute(): Page
    {
        /** @var Page $page */
        $page = $this->resultPageFactory->create();
        $page->setActiveMenu('PocketCoffee_RoastAdmin::batches');
        $page->getConfig()->getTitle()->prepend(
            $this->getRequest()->getParam('id') ? __('Edit roast batch') : __('New roast batch')
        );

        return $page;
    }
}
<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Controller\Adminhtml\Batch;

use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\Controller\Result\Forward;
use Magento\Framework\Controller\Result\ForwardFactory;

/**
 * "new" is a PHP reserved word, so the class is NewAction; the framework maps the URL segment "new" to it.
 */
class NewAction extends Action implements HttpGetActionInterface
{
    public const ADMIN_RESOURCE = 'PocketCoffee_RoastAdmin::batches_save';

    public function __construct(Context $context, private readonly ForwardFactory $forwardFactory)
    {
        parent::__construct($context);
    }

    public function execute(): Forward
    {
        return $this->forwardFactory->create()->forward('edit');
    }
}
<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Controller\Adminhtml\Batch;

use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\Controller\Result\Redirect;
use Magento\Framework\Exception\LocalizedException;
use PocketCoffee\RoastAdmin\Model\BatchFactory;
use PocketCoffee\RoastAdmin\Model\ResourceModel\Batch as BatchResource;

/**
 * POST {admin}/pocketcoffee_roast/batch/save. The Admin form key is validated by the framework.
 */
class Save extends Action implements HttpPostActionInterface
{
    public const ADMIN_RESOURCE = 'PocketCoffee_RoastAdmin::batches_save';

    public function __construct(
        Context $context,
        private readonly BatchFactory $batchFactory,
        private readonly BatchResource $resource
    ) {
        parent::__construct($context);
    }

    public function execute(): Redirect
    {
        $redirect = $this->resultRedirectFactory->create();
        $data = (array) $this->getRequest()->getPostValue();
        $id = (int) ($data['batch_id'] ?? 0);

        $batch = $this->batchFactory->create();
        try {
            if ($id) {
                $this->resource->load($batch, $id);
                if (!$batch->getId()) {
                    throw new LocalizedException(__('This roast batch no longer exists.'));
                }
            }
            $batch->setData([
                'batch_id' => $id ?: null,
                'reference' => trim((string) ($data['reference'] ?? '')),
                'origin' => (string) ($data['origin'] ?? ''),
                'roast_date' => (string) ($data['roast_date'] ?? ''),
                'quantity_kg' => (float) ($data['quantity_kg'] ?? 0),
                'is_active' => (int) ($data['is_active'] ?? 0),
            ]);
            $this->resource->save($batch);
            $this->messageManager->addSuccessMessage(__('The roast batch has been saved.'));

            return $redirect->setPath('*/*/');
        } catch (LocalizedException $e) {
            $this->messageManager->addErrorMessage($e->getMessage());
        } catch (\Throwable $e) {
            $this->messageManager->addExceptionMessage($e, __('Something went wrong while saving the batch.'));
        }

        return $redirect->setPath('*/*/edit', ['id' => $id ?: null]);
    }
}
<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Controller\Adminhtml\Batch;

use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\Controller\Result\Redirect;
use PocketCoffee\RoastAdmin\Model\BatchFactory;
use PocketCoffee\RoastAdmin\Model\ResourceModel\Batch as BatchResource;

/**
 * POST {admin}/pocketcoffee_roast/batch/delete/id/3 from the row action or the form button.
 */
class Delete extends Action implements HttpPostActionInterface
{
    public const ADMIN_RESOURCE = 'PocketCoffee_RoastAdmin::batches_delete';

    public function __construct(
        Context $context,
        private readonly BatchFactory $batchFactory,
        private readonly BatchResource $resource
    ) {
        parent::__construct($context);
    }

    public function execute(): Redirect
    {
        $batch = $this->batchFactory->create();
        $this->resource->load($batch, (int) $this->getRequest()->getParam('id'));
        if ($batch->getId()) {
            $this->resource->delete($batch);
            $this->messageManager->addSuccessMessage(__('The roast batch has been deleted.'));
        } else {
            $this->messageManager->addErrorMessage(__('This roast batch no longer exists.'));
        }

        return $this->resultRedirectFactory->create()->setPath('*/*/');
    }
}
<?php

declare(strict_types=1);

namespace PocketCoffee\RoastAdmin\Controller\Adminhtml\Batch;

use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\Controller\Result\Redirect;
use Magento\Ui\Component\MassAction\Filter;
use PocketCoffee\RoastAdmin\Model\ResourceModel\Batch\CollectionFactory;

/**
 * POST from the listing's mass action: the Filter turns the "selected" or "select all" request into a collection.
 */
class MassDelete extends Action implements HttpPostActionInterface
{
    public const ADMIN_RESOURCE = 'PocketCoffee_RoastAdmin::batches_delete';

    public function __construct(
        Context $context,
        private readonly Filter $filter,
        private readonly CollectionFactory $collectionFactory
    ) {
        parent::__construct($context);
    }

    public function execute(): Redirect
    {
        $collection = $this->filter->getCollection($this->collectionFactory->create());
        $count = 0;
        foreach ($collection as $batch) {
            $batch->delete();
            $count++;
        }
        $this->messageManager->addSuccessMessage(__('A total of %1 roast batch(es) have been deleted.', $count));

        return $this->resultRedirectFactory->create()->setPath('*/*/');
    }
}

Each class declares its own ADMIN_RESOURCE, which is what makes "view" and "change" separable. NewAction is the class name for the URL segment new, because new is a reserved word in PHP; it forwards to Edit, so the form is the same. Save reads the posted fields, loads the record when an id is present, saves it through the resource model, and answers with a redirect and a message. A message manager call (addSuccessMessage) stores the text in the session for the next Admin page. MassDelete uses Magento\Ui\Component\MassAction\Filter to translate the posted selection or the filter into a collection.

The observed behaviour of the actions, as the administrator over HTTP:

Request Result

POST batch/save with a new reference, origin, date and quantity

302 to the grid; the row appeared in the table with is_active 1 and the current created_at.

POST batch/save with an existing reference

302 back to the edit page with an error message (the unique key rejects the row). The failed insert still consumed an AUTO_INCREMENT value, so the next batch had id 3, not 2: do not rely on gapless ids.

POST batch/save without form_key

302 to the dashboard; nothing saved.

GET batch/save

404.

GET batch/delete/id/1

404 (POST only).

POST batch/delete/id/1 with the form key

302 to the grid; the row was deleted.

POST batch/massDelete with selected[]=4 and selected[]=5 and the namespace

302 to the grid; both rows were deleted.

Adding to existing Admin screens

Everything above creates new screens. To add a tab or a field to an existing screen (the product form, the customer form, the order view) use the screen’s own extension point instead of copying it: a modifier class registered in the modifier pool of the product form, a layout reference to the order view’s blocks, or the UI component merge shown for the category form in EAV and Attributes, where one file in view/adminhtml/ui_component/ named like the core component is merged into it. These were not run for this page beyond the category form of the other page.

Pitfalls

  • The ACL id must exist in acl.xml. A typo in ADMIN_RESOURCE or resource locks everyone but the full administrator out of the screen or the menu entry, and it appears in no role until it is declared. After editing acl.xml, clear the cache and re-save the roles that should receive the new resources: existing restricted roles do not gain them automatically.

  • mui/index/render is a second entry point. Declare aclResource on every listing and form that exposes rows.

  • Client-side validation is not validation. <validate> in system.xml and the validation rules of a form field run in the browser. Validate again in the backend model or in the controller.

  • Secrets in config.xml or in a di.xml default. Defaults are shipped with the module and visible to everyone; use the encrypted backend model and the sensitive list, and set the value per environment.

  • Changing the name of a UI component or data source after release. Saved bookmarks (user views) in ui_bookmark refer to the namespace; a rename drops them.

  • A grid with no indexField or with a different primaryFieldName shows the rows but breaks selection and row actions.

  • Non-unique sortOrder values in menu.xml give an unstable menu order between environments.

  • Controllers named after reserved words. New, List and Print are not valid class names; use NewAction, ListAction, PrintAction.

  • Template path in the Admin differs. Admin templates and layouts are under view/adminhtml/, and view/base/ is merged into both areas.

  • Locked values. A value written with config:set --lock-env or --lock-config is read-only in the Admin; remember that when a merchant reports that "the field does not save".

What was and was not run

On Mage-OS 3.6.0 (Magento 2.4.9 base, PHP 8.5.12, MariaDB 11.8): php -l and phpcs --standard=Magento2 on the module (no errors), XSD validation of every XML file of the module (module.xml, db_schema.xml, acl.xml, menu.xml, routes.xml, system.xml, config.xml, di.xml, the two layouts and the two UI components), module:enable, the whitelist generation, setup:upgrade, cache:flush, the Admin sign-in and the requests listed above as the full administrator and as the restricted role, the configuration screen (rendered fields, type="obscure" as a password input, depends), configuration saves through the Admin and the CLI, the environment variable override and the settings command. Not run: a real browser (the Knockout rendering of the grid and the form, the buttonAdapter of the Save button, inline editing and bookmarks), the Admin two-factor authentication (the module is disabled on the test store), Adobe IMS sign-in, app:config:dump, --lock-env and --lock-config, the role editor UI, the Admin UI SDK and any Adobe Commerce-only Admin feature.