create-backend-controller
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.
$ インストール
git clone https://github.com/ProxiBlue/claude-skills /tmp/claude-skills && cp -r /tmp/claude-skills/create-backend-controller ~/.claude/skills/claude-skills// tip: Run this command in your terminal to install the skill
name: create-backend-controller description: 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.
Create Backend (Adminhtml) Controller Action
Description
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.
When to Use
- Creating custom admin pages or sections
- Building AJAX endpoints for admin UI components
- Implementing admin form submission handlers
- Creating mass actions for grid components
- Building custom admin operations requiring authorization
Prerequisites
- Existing Magento 2 module with proper structure
- Understanding of ACL (Access Control List) system
- Knowledge of Magento routing and dependency injection
- Understanding of admin sessions and authorization
Best Practices from Adobe Documentation
1. Extend Backend Action Base Class
Backend controllers should extend \Magento\Backend\App\Action:
class ActionName extends \Magento\Backend\App\Action implements HttpGetActionInterface
2. Implement HTTP Method-Specific Interfaces
Always implement HTTP method-specific action interfaces:
HttpGetActionInterface- For GET requestsHttpPostActionInterface- For POST requests- Both interfaces can be implemented for endpoints accepting multiple methods
3. Define ACL Resource Constant
Every backend controller must define the ADMIN_RESOURCE constant:
const ADMIN_RESOURCE = 'Vendor_Module::resource_name';
4. Use Strict Types
Always declare strict types at the top of controller files:
declare(strict_types=1);
5. Authorization is Automatic
The \Magento\Backend\App\Action base class automatically checks the ADMIN_RESOURCE constant against the current admin user's permissions via the _isAllowed() method.
Step-by-Step Implementation
Step 1: Define ACL Resources (acl.xml)
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:
- Each resource has a unique ID (e.g.,
Vendor_Module::entity_save) - Resources are hierarchical - child resources inherit parent permissions
- Admin users must have permission for the resource to access the controller
Step 2: Create Backend Routes (routes.xml)
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
Step 3: Create Admin Menu (menu.xml) [Optional]
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>
Step 4: Create Controller Directory Structure
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
Step 5: Create Backend Controller Action Class
Example 1: Admin Grid Page Controller
<?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;
}
}
Example 2: JSON Response Controller (AJAX Endpoint)
<?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
]);
}
}
Example 3: Save Action with Form Key Validation
<?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]);
}
}
Example 4: Mass Action Controller
<?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('*/*/');
}
}
Example 5: Delete Action
<?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('*/*/');
}
}
Step 6: Create Layout XML
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>
Step 7: Clear Cache and Test
# 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
Common Patterns
Pattern 1: Inline Edit (AJAX Save)
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
]);
}
Pattern 2: Custom Authorization Check
/**
* 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;
}
Pattern 3: File Upload in Admin Form
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...
}
Testing Admin Controllers
Unit Test Example
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
}
}
Troubleshooting
Issue: Access Denied (403)
- Check ACL resource is defined in
etc/acl.xml - Verify
ADMIN_RESOURCEconstant matches ACL resource ID - Ensure admin user role has permission for the resource
- Run
ddev exec bin/magento cache:flush - Check Stores > Configuration > Admin > Admin Base URL
Issue: 404 Not Found
- Verify
routes.xmlis inetc/adminhtml/(notetc/frontend/) - Check frontName is unique and doesn't conflict
- Ensure controller extends
\Magento\Backend\App\Action - Run
ddev exec bin/magento setup:upgrade
Issue: Form Key Validation Failed
- Ensure form includes form key:
<?= $block->getFormKey() ?> - POST requests automatically validate form keys
- For AJAX, include form key in data
Issue: Menu Not Showing
- Check
menu.xmlis inetc/adminhtml/ - Verify ACL resource permissions
- Clear admin cache:
ddev exec bin/magento cache:clean config - Check admin user has permission to resource
Security Best Practices
- Always Define ACL Resources: Never use
const ADMIN_RESOURCE = 'Magento_Backend::admin'for production controllers - Validate Input: Use input validators and filters
- Use Form Keys: Magento automatically validates form keys for POST requests
- Escape Output: Use
$escaper->escapeHtml()in templates - Check Permissions: Let
_isAllowed()handle authorization - Use Type Hints: Ensure strict types are declared
- Log Sensitive Actions: Use logger for delete/update operations
References
- Adobe Commerce Frontend Core: https://github.com/adobedocs/commerce-frontend-core
- Magento 2 Backend Development: https://developer.adobe.com/commerce/php/development/components/
- ACL Documentation: https://developer.adobe.com/commerce/php/tutorials/backend/create-access-control-list-rule/
- Admin UI Components: https://developer.adobe.com/commerce/frontend-core/ui-components/
NTOTanks-Specific Notes
- Follow PSR-12 coding standards
- Use
ddev execprefix for all Magento CLI commands - Backend controllers integrate with Hyvä Admin module for UI components
- Test admin controllers after clearing cache and recompiling
- Check admin user permissions in System > User Roles
Repository
