Controllers and Actions
A controller class answers one action through execute(), and marker interfaces decide which HTTP methods may reach it.
class Save implements \Magento\Framework\App\Action\HttpPostActionInterface { public function execute() }A Magento controller answers exactly one action. Vendor\Feedback\Controller\Index\Save answers /feedback/index/save, and nothing else. The method the framework calls is always execute(), which takes no arguments; request input arrives through Magento\Framework\App\RequestInterface, injected in the constructor.
Marker interfaces
Implement Magento\Framework\App\ActionInterface, or one of the HTTP-method interfaces that extend it, added in 2.3.0: HttpGetActionInterface, HttpPostActionInterface, HttpPutActionInterface, HttpPatchActionInterface, HttpDeleteActionInterface, HttpOptionsActionInterface and a few rarer ones, all in Magento\Framework\App\Action. They declare no methods. If your class implements at least one, a request using any other method is refused. If it implements none, every mapped method is allowed.
HttpGetActionInterface extends HttpHeadActionInterface, so a GET action also answers HEAD.
Form keys
In the frontend and adminhtml areas, a non-AJAX POST must carry a valid form key or the request is redirected back with Invalid Form Key. Please refresh the page. AJAX POSTs skip that check. To accept a POST from outside, such as a payment notification, implement Magento\Framework\App\CsrfAwareActionInterface and return true from validateForCsrf().
What trips people up
Extending Magento\Framework\App\Action\Action still works, but it was deprecated in magento/framework 103.0.0, which ships with Magento 2.4.0. It hides your dependencies behind a Context object and applies no method restriction. New storefront controllers implement the interfaces instead.
Example
<?php
// app/code/Vendor/Feedback/Controller/Index/Save.php
declare(strict_types=1);
namespace Vendor\Feedback\Controller\Index;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\RedirectFactory;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\Message\ManagerInterface;
use Psr\Log\LoggerInterface;
class Save implements HttpPostActionInterface
{
public function __construct(
private readonly RequestInterface $request,
private readonly RedirectFactory $redirectFactory,
private readonly ManagerInterface $messageManager,
private readonly LoggerInterface $logger
) {
}
public function execute(): ResultInterface
{
$comment = trim((string)$this->request->getParam('comment', ''));
if ($comment === '') {
$this->messageManager->addErrorMessage(__('Enter a comment before sending.'));
} else {
$this->logger->info('feedback received', ['comment' => $comment]);
$this->messageManager->addSuccessMessage(__('Thank you for the feedback.'));
}
return $this->redirectFactory->create()->setPath('feedback/index/index');
}
}When to use it
- Add a form endpoint that must reject GET requests.
- Accept a payment provider's webhook POST without a Magento form key.
- Work out why your admin controller returns to the login screen instead of running.
- Replace a controller that extends the deprecated Action base class.
More examples
A webhook that opts out of the form key
Returning true from validateForCsrf() skips the form key check for this action alone; returning null falls back to the default check. Returning null from createCsrfValidationException() lets Magento build its standard 302 response when validation does fail. This is the supported way to accept an outside POST. Because you have removed the only check that was there, add your own: verify a shared secret or a signature header before acting on the payload.
<?php
// app/code/Vendor/Payment/Controller/Webhook/Index.php
declare(strict_types=1);
namespace Vendor\Payment\Controller\Webhook;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Framework\Controller\ResultInterface;
class Index implements HttpPostActionInterface, CsrfAwareActionInterface
{
public function __construct(
private readonly JsonFactory $jsonFactory
) {
}
public function createCsrfValidationException(
RequestInterface $request
): ?InvalidRequestException {
return null;
}
public function validateForCsrf(RequestInterface $request): ?bool
{
return true;
}
public function execute(): ResultInterface
{
return $this->jsonFactory->create()->setData(['received' => true]);
}
}The legacy base class you will meet in older modules
Magento\Framework\App\Action\Action and its parent AbstractAction were deprecated in magento/framework 103.0.0 (Magento 2.4.0) in favour of composition. The Context argument carries the request, the response and the result factory into protected properties, so the class's real dependencies are invisible from its constructor. It also implements no HTTP-method interface, so this controller answers GET, POST and DELETE alike. When you convert one, inject what the body actually uses and implement the matching method interface.
<?php
// app/code/Vendor/Legacy/Controller/Index/Index.php - recognise it, do not copy it
declare(strict_types=1);
namespace Vendor\Legacy\Controller\Index;
use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Controller\ResultInterface;
class Index extends Action
{
public function __construct(Context $context)
{
parent::__construct($context);
}
public function execute(): ResultInterface
{
return $this->resultFactory->create(ResultFactory::TYPE_PAGE);
}
}An admin controller
Admin controllers are the exception to avoiding base classes: the admin session check and the permission check are attached to Magento\Backend\App\AbstractAction, so a class that does not descend from it is reachable without logging in. Override the ADMIN_RESOURCE constant with the identifier your module declares in etc/acl.xml; the inherited _isAllowed() compares it against the current user's permissions and returns the access-denied page when it fails. The class still declares HttpGetActionInterface, because the base class imposes no method restriction of its own.
<?php
// app/code/Vendor/Blog/Controller/Adminhtml/Post/Index.php
declare(strict_types=1);
namespace Vendor\Blog\Controller\Adminhtml\Post;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\View\Result\PageFactory;
class Index extends Action implements HttpGetActionInterface
{
public const ADMIN_RESOURCE = 'Vendor_Blog::post';
public function __construct(
Context $context,
private readonly PageFactory $pageFactory
) {
parent::__construct($context);
}
public function execute(): ResultInterface
{
return $this->pageFactory->create();
}
}
Discussion