Creates a backend (adminhtml) controller action in Magento 2 with proper ACL, routing, authorization, and admin UI integration. Use when building admin pages, AJAX endpoints, form handlers, or mass actions.
This skill guides you through creating a backend controller action in Adobe Commerce/Magento 2 (Mage-OS) for the admin area. Backend controllers handle HTTP requests in the Magento admin panel with proper authorization and ACL (Access Control List) integration.
Backend controllers should extend \Magento\Backend\App\Action:
class ActionName extends \Magento\Backend\App\Action implements HttpGetActionInterface
Always implement HTTP method-specific action interfaces:
HttpGetActionInterface - For GET requestsHttpPostActionInterface - For POST requestsEvery backend controller must define the ADMIN_RESOURCE constant:
const ADMIN_RESOURCE = 'Vendor_Module::resource_name';
Always declare strict types at the top of controller files:
declare(strict_types=1);
The \Magento\Backend\App\Action base class automatically checks the ADMIN_RESOURCE constant against the current admin user's permissions via the _isAllowed() method.
Create etc/acl.xml to define access control resources:
<?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">
<!-- Main module menu resource -->
<resource id="Vendor_Module::menu" title="Module Name" sortOrder="100">
<!-- Sub-resource for entities -->
<resource id="Vendor_Module::entity" title="Manage Entities" sortOrder="10">
<resource id="Vendor_Module::entity_save" title="Save Entity" sortOrder="10" />
<resource id="Vendor_Module::entity_delete" title="Delete Entity" sortOrder="20" />
</resource>
<!-- Configuration resource -->
<resource id="Vendor_Module::config" title="Configuration" sortOrder="20" />
</resource>
</resource>
</resources>
</acl>
</config>
ACL Resource Structure:
Vendor_Module::entity_save)Define your route configuration in etc/adminhtml/routes.xml:
<?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="vendormodule" frontName="vendormodule">
<module name="Vendor_Module" before="Magento_Backend" />
</route>
</router>
</config>
URL Structure: https://yourdomain.com/admin/{frontName}/{controller}/{action}
Example: With frontName vendormodule, the URL would be:
https://yourdomain.com/admin/vendormodule/entity/index
Create etc/adminhtml/menu.xml to add menu items:
<?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>
<!-- Top-level menu -->
<add id="Vendor_Module::menu"
title="Module Name"
module="Vendor_Module"
sortOrder="100"
resource="Vendor_Module::menu"/>
<!-- Sub-menu item linking to controller -->
<add id="Vendor_Module::entity"
title="Manage Entities"
module="Vendor_Module"
sortOrder="10"
parent="Vendor_Module::menu"
action="vendormodule/entity/index"
resource="Vendor_Module::entity"/>
<!-- Configuration menu item -->
<add id="Vendor_Module::settings"
title="Settings"
module="Vendor_Module"
sortOrder="20"
parent="Vendor_Module::menu"
action="adminhtml/system_config/edit/section/vendormodule"
resource="Vendor_Module::config"/>
</menu>
</config>
Create the controller directory:
app/code/Vendor/ModuleName/Controller/Adminhtml/
└── ControllerName/
└── ActionName.php
Example: Controller/Adminhtml/Entity/Index.php maps to URL: /admin/vendormodule/entity/index
<?php
/**
* Copyright © [Year] [Your Company]
* All rights reserved.
*/
declare(strict_types=1);
namespace Vendor\Module\Controller\Adminhtml\Entity;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\PageFactory;
use Magento\Framework\View\Result\Page;
class Index extends Action implements HttpGetActionInterface
{
/**
* Authorization level of a basic admin session
*
* @see _isAllowed()
*/
const ADMIN_RESOURCE = 'Vendor_Module::entity';
/**
* @var PageFactory
*/
private PageFactory $resultPageFactory;
/**
* Constructor
*
* @param Context $context
* @param PageFactory $resultPageFactory
*/
public function __construct(
Context $context,
PageFactory $resultPageFactory
) {
parent::__construct($context);
$this->resultPageFactory = $resultPageFactory;
}
/**
* Execute action
*
* @return Page
*/
public function execute(): Page
{
/** @var Page $resultPage */
$resultPage = $this->resultPageFactory->create();
$resultPage->setActiveMenu('Vendor_Module::entity');
$resultPage->getConfig()->getTitle()->prepend(__('Manage Entities'));
return $resultPage;
}
}
<?php
/**
* Copyright © [Year] [Your Company]
* All rights reserved.
*/
declare(strict_types=1);
namespace Vendor\Module\Controller\Adminhtml\Entity;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Framework\Controller\ResultInterface;
use Vendor\Module\Model\ResourceModel\Entity\CollectionFactory;
class Search extends Action implements HttpGetActionInterface, HttpPostActionInterface
{
/**
* Authorization level of a basic admin session
*
* @see _isAllowed()
*/
const ADMIN_RESOURCE = 'Vendor_Module::entity';
/**
* @var JsonFactory
*/
private JsonFactory $resultJsonFactory;
/**
* @var CollectionFactory
*/
private CollectionFactory $collectionFactory;
/**
* Constructor
*
* @param Context $context
* @param JsonFactory $resultJsonFactory
* @param CollectionFactory $collectionFactory
*/
public function __construct(
Context $context,
JsonFactory $resultJsonFactory,
CollectionFactory $collectionFactory
) {
parent::__construct($context);
$this->resultJsonFactory = $resultJsonFactory;
$this->collectionFactory = $collectionFactory;
}
/**
* Execute action
*
* @return ResultInterface
*/
public function execute(): ResultInterface
{
$searchKey = $this->getRequest()->getParam('searchKey');
$pageNum = (int)$this->getRequest()->getParam('page', 1);
$limit = (int)$this->getRequest()->getParam('limit', 10);
/** @var \Vendor\Module\Model\ResourceModel\Entity\Collection $collection */
$collection = $this->collectionFactory->create();
$collection->addFieldToFilter('name', ['like' => "%{$searchKey}%"]);
$collection->setCurPage($pageNum)->setPageSize($limit);
$totalValues = $collection->getSize();
$results = [];
foreach ($collection as $entity) {
$results[$entity->getId()] = [
'value' => $entity->getId(),
'label' => $entity->getName(),
'identifier' => sprintf(__('ID: %s'), $entity->getId())
];
}
/** @var \Magento\Framework\Controller\Result\Json $resultJson */
$resultJson = $this->resultJsonFactory->create();
return $resultJson->setData([
'options' => $results,
'total' => empty($results) ? 0 : $totalValues
]);
}
}
<?php
/**
* Copyright © [Year] [Your Company]
* All rights reserved.
*/
declare(strict_types=1);
namespace Vendor\Module\Controller\Adminhtml\Entity;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\Exception\LocalizedException;
use Vendor\Module\Api\EntityRepositoryInterface;
use Vendor\Module\Model\EntityFactory;
class Save extends Action implements HttpPostActionInterface
{
/**
* Authorization level of a basic admin session
*
* @see _isAllowed()
*/
const ADMIN_RESOURCE = 'Vendor_Module::entity_save';
/**
* @var EntityFactory
*/
private EntityFactory $entityFactory;
/**
* @var EntityRepositoryInterface
*/
private EntityRepositoryInterface $entityRepository;
/**
* Constructor
*
* @param Context $context
* @param EntityFactory $entityFactory
* @param EntityRepositoryInterface $entityRepository
*/
public function __construct(
Context $context,
EntityFactory $entityFactory,
EntityRepositoryInterface $entityRepository
) {
parent::__construct($context);
$this->entityFactory = $entityFactory;
$this->entityRepository = $entityRepository;
}
/**
* Execute action
*
* @return ResultInterface
*/
public function execute(): ResultInterface
{
$resultRedirect = $this->resultRedirectFactory->create();
$data = $this->getRequest()->getPostValue();
if (!$data) {
$this->messageManager->addErrorMessage(__('No data to save.'));
return $resultRedirect->setPath('*/*/');
}
try {
$entityId = $this->getRequest()->getParam('entity_id');
if ($entityId) {
$entity = $this->entityRepository->getById($entityId);
} else {
$entity = $this->entityFactory->create();
}
$entity->setData($data);
$this->entityRepository->save($entity);
$this->messageManager->addSuccessMessage(__('Entity saved successfully.'));
if ($this->getRequest()->getParam('back')) {
return $resultRedirect->setPath('*/*/edit', ['id' => $entity->getId()]);
}
return $resultRedirect->setPath('*/*/');
} catch (LocalizedException $e) {
$this->messageManager->addErrorMessage($e->getMessage());
} catch (\Exception $e) {
$this->messageManager->addExceptionMessage(
$e,
__('Something went wrong while saving the entity.')
);
}
return $resultRedirect->setPath('*/*/edit', ['id' => $entityId ?? null]);
}
}
<?php
/**
* Copyright © [Year] [Your Company]
* All rights reserved.
*/
declare(strict_types=1);
namespace Vendor\Module\Controller\Adminhtml\Entity;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\Exception\LocalizedException;
use Vendor\Module\Api\EntityRepositoryInterface;
use Vendor\Module\Model\ResourceModel\Entity\CollectionFactory;
use Magento\Ui\Component\MassAction\Filter;
class MassDelete extends Action implements HttpPostActionInterface
{
/**
* Authorization level of a basic admin session
*
* @see _isAllowed()
*/
const ADMIN_RESOURCE = 'Vendor_Module::entity_delete';
/**
* @var Filter
*/
private Filter $filter;
/**
* @var CollectionFactory
*/
private CollectionFactory $collectionFactory;
/**
* @var EntityRepositoryInterface
*/
private EntityRepositoryInterface $entityRepository;
/**
* Constructor
*
* @param Context $context
* @param Filter $filter
* @param CollectionFactory $collectionFactory
* @param EntityRepositoryInterface $entityRepository
*/
public function __construct(
Context $context,
Filter $filter,
CollectionFactory $collectionFactory,
EntityRepositoryInterface $entityRepository
) {
parent::__construct($context);
$this->filter = $filter;
$this->collectionFactory = $collectionFactory;
$this->entityRepository = $entityRepository;
}
/**
* Execute action
*
* @return ResultInterface
*/
public function execute(): ResultInterface
{
try {
$collection = $this->filter->getCollection($this->collectionFactory->create());
$deletedCount = 0;
foreach ($collection as $entity) {
$this->entityRepository->delete($entity);
$deletedCount++;
}
$this->messageManager->addSuccessMessage(
__('A total of %1 record(s) have been deleted.', $deletedCount)
);
} catch (LocalizedException $e) {
$this->messageManager->addErrorMessage($e->getMessage());
} catch (\Exception $e) {
$this->messageManager->addExceptionMessage(
$e,
__('An error occurred while deleting records.')
);
}
/** @var \Magento\Backend\Model\View\Result\Redirect $resultRedirect */
$resultRedirect = $this->resultFactory->create(ResultFactory::TYPE_REDIRECT);
return $resultRedirect->setPath('*/*/');
}
}
<?php
/**
* Copyright © [Year] [Your Company]
* All rights reserved.
*/
declare(strict_types=1);
namespace Vendor\Module\Controller\Adminhtml\Entity;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\Exception\LocalizedException;
use Vendor\Module\Api\EntityRepositoryInterface;
class Delete extends Action implements HttpPostActionInterface
{
/**
* Authorization level of a basic admin session
*
* @see _isAllowed()
*/
const ADMIN_RESOURCE = 'Vendor_Module::entity_delete';
/**
* @var EntityRepositoryInterface
*/
private EntityRepositoryInterface $entityRepository;
/**
* Constructor
*
* @param Context $context
* @param EntityRepositoryInterface $entityRepository
*/
public function __construct(
Context $context,
EntityRepositoryInterface $entityRepository
) {
parent::__construct($context);
$this->entityRepository = $entityRepository;
}
/**
* Execute action
*
* @return ResultInterface
*/
public function execute(): ResultInterface
{
$resultRedirect = $this->resultRedirectFactory->create();
$id = $this->getRequest()->getParam('id');
if (!$id) {
$this->messageManager->addErrorMessage(__('Entity ID is required.'));
return $resultRedirect->setPath('*/*/');
}
try {
$this->entityRepository->deleteById((int)$id);
$this->messageManager->addSuccessMessage(__('Entity deleted successfully.'));
} catch (LocalizedException $e) {
$this->messageManager->addErrorMessage($e->getMessage());
} catch (\Exception $e) {
$this->messageManager->addExceptionMessage(
$e,
__('An error occurred while deleting the entity.')
);
}
return $resultRedirect->setPath('*/*/');
}
}
Create layout XML: view/adminhtml/layout/vendormodule_entity_index.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">
<update handle="styles"/>
<body>
<referenceContainer name="content">
<uiComponent name="vendor_module_entity_listing"/>
</referenceContainer>
</body>
</page>
# Clear cache
ddev exec bin/magento cache:flush
# Upgrade setup (for new ACL resources)
ddev exec bin/magento setup:upgrade
# Compile if needed
ddev exec bin/magento setup:di:compile
# Test access to the admin controller
# Navigate to: https://ntotank.ddev.site/admin/vendormodule/entity/index
public function execute(): ResultInterface
{
$resultJson = $this->resultJsonFactory->create();
$items = $this->getRequest()->getParam('items', []);
if (empty($items)) {
return $resultJson->setData([
'messages' => [__('Please correct the data sent.')],
'error' => true
]);
}
foreach ($items as $entityId => $entityData) {
try {
$entity = $this->entityRepository->getById($entityId);
$entity->setData(array_merge($entity->getData(), $entityData));
$this->entityRepository->save($entity);
} catch (\Exception $e) {
return $resultJson->setData([
'messages' => [$e->getMessage()],
'error' => true
]);
}
}
return $resultJson->setData([
'messages' => [__('Records saved.')],
'error' => false
]);
}
/**
* Check if admin has permission
*
* @return bool
*/
protected function _isAllowed(): bool
{
// Custom authorization logic
$isAllowed = $this->_authorization->isAllowed('Vendor_Module::entity');
// Additional custom checks
if ($isAllowed && $this->getRequest()->getParam('special_flag')) {
$isAllowed = $this->_authorization->isAllowed('Vendor_Module::special_permission');
}
return $isAllowed;
}
public function execute(): ResultInterface
{
$data = $this->getRequest()->getPostValue();
// Handle file upload
if (isset($_FILES['image']) && $_FILES['image']['name']) {
try {
$uploader = $this->uploaderFactory->create(['fileId' => 'image']);
$uploader->setAllowedExtensions(['jpg', 'jpeg', 'gif', 'png']);
$uploader->setAllowRenameFiles(true);
$uploader->setFilesDispersion(true);
$result = $uploader->save(
$this->mediaDirectory->getAbsolutePath('vendor_module/entity/')
);
$data['image'] = 'vendor_module/entity' . $result['file'];
} catch (\Exception $e) {
$this->messageManager->addErrorMessage($e->getMessage());
}
}
// Continue with save logic...
}
Create: Test/Unit/Controller/Adminhtml/Entity/SaveTest.php
<?php
declare(strict_types=1);
namespace Vendor\Module\Test\Unit\Controller\Adminhtml\Entity;
use PHPUnit\Framework\TestCase;
use Vendor\Module\Controller\Adminhtml\Entity\Save;
class SaveTest extends TestCase
{
public function testExecuteWithValidData(): void
{
// Setup mocks
$context = $this->createMock(\Magento\Backend\App\Action\Context::class);
$entityFactory = $this->createMock(\Vendor\Module\Model\EntityFactory::class);
$entityRepository = $this->createMock(\Vendor\Module\Api\EntityRepositoryInterface::class);
// Create controller instance
$controller = new Save($context, $entityFactory, $entityRepository);
// Test execution
// Add assertions here
}
}
etc/acl.xmlADMIN_RESOURCE constant matches ACL resource IDddev exec bin/magento cache:flushroutes.xml is in etc/adminhtml/ (not etc/frontend/)\Magento\Backend\App\Actionddev exec bin/magento setup:upgrade<?= $block->getFormKey() ?>menu.xml is in etc/adminhtml/ddev exec bin/magento cache:clean configconst ADMIN_RESOURCE = 'Magento_Backend::admin' for production controllers$escaper->escapeHtml() in templates_isAllowed() handle authorizationddev exec prefix for all Magento CLI commandsnpx skills add ProxiBlue/create-backend-controller下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer