A Page of Your Own

routes.xml claims a URL prefix and a controller answers it by returning a page result.

Syntax<route id="faq" frontName="faq"><module name="Acme_Faq"/></route>

A storefront URL in Magento has three segments: /<frontName>/<controller>/<action>. Claiming a front name takes one XML file. Answering it takes one class.

The route

routes.xml goes in etc/frontend/. An area is one of the applications Magento serves from the same codebase, such as frontend (the storefront), adminhtml (the admin) or webapi_rest; configuration under etc/<area>/ applies to that area alone. A route in etc/frontend/routes.xml exists on the storefront and not in the admin.

frontName is the word that appears in the URL. id is the internal name used when building URLs and naming layout files. They are usually the same word.

The controller

The remaining segments become a class path: /faq/index/index maps to Controller/Index/Index.php. Both segments default to index, so /faq reaches the same class. Implement HttpGetActionInterface to declare that the action answers GET, and return a result object from execute() rather than printing.

Longhand and shorthand

$this->pageFactory->create() and $this->resultFactory->create(ResultFactory::TYPE_PAGE) return the same Magento\Framework\View\Result\Page. The factory is the typed shorthand for the generic one.

After editing

Run bin/magento setup:upgrade for a new module and bin/magento cache:flush for the route, because routes are merged configuration and cached. In production mode also run bin/magento setup:di:compile. A 404 that survives a correct fix is almost always the config cache.

Example

Example · php
<?php
// app/code/Acme/Faq/Controller/Index/Index.php

declare(strict_types=1);

namespace Acme\Faq\Controller\Index;

use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\View\Result\PageFactory;

class Index implements HttpGetActionInterface
{
    public function __construct(
        private readonly PageFactory $pageFactory
    ) {
    }

    public function execute(): ResultInterface
    {
        $page = $this->pageFactory->create();
        $page->getConfig()->getTitle()->set(__('Questions'));

        return $page;
    }
}

// GET https://example.test/faq returns 200, as does /faq/index/index.
// The browser shows the themed page - header, navigation and footer - with the
// title "Questions" and an empty main content area, because no block has been
// added to the page yet.

When to use it

  • Adding a standalone storefront page such as /faq or /size-guide.
  • Giving storefront JavaScript a small JSON endpoint of your own.
  • Tracing which class answers an unfamiliar URL on a site you have inherited.
  • Fixing a 404 on a route that was added but never had its cache flushed.

More examples

The route that makes the controller reachable

router id="standard" is the storefront router; the admin uses id="admin" in etc/adminhtml/routes.xml. The module element says which module's Controller directory to search, so several modules can add actions under one front name. Nothing here points at a class: the URL segments do that. Apply the file with bin/magento cache:flush, and the storefront then serves /faq instead of a 404.

Example · xml
<!-- app/code/Acme/Faq/etc/frontend/routes.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <router id="standard">
        <route id="faq" frontName="faq">
            <module name="Acme_Faq"/>
        </route>
    </router>
</config>

The same page, written with the generic result factory

ResultFactory maps a type constant to a class: TYPE_PAGE, TYPE_JSON, TYPE_RAW, TYPE_REDIRECT, TYPE_FORWARD, TYPE_LAYOUT. This returns exactly the object PageFactory returns, so the two forms are interchangeable. Reach for the generic factory when one action can return different kinds of result, and for the typed factory when it always returns one, since the typed one documents itself.

Example · php
<?php
// app/code/Acme/Faq/Controller/Index/Index.php

declare(strict_types=1);

namespace Acme\Faq\Controller\Index;

use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Controller\ResultInterface;

class Index implements HttpGetActionInterface
{
    public function __construct(
        private readonly ResultFactory $resultFactory
    ) {
    }

    public function execute(): ResultInterface
    {
        return $this->resultFactory->create(ResultFactory::TYPE_PAGE);
    }
}

An action that answers with JSON

setData serialises the array for you, so never echo json_encode output from a controller. A JSON action is the usual way to serve a small piece of data to storefront JavaScript. For anything a third party consumes, prefer a declared web API endpoint, which handles authentication and versioning.

Example · php
<?php
// app/code/Acme/Faq/Controller/Index/Ping.php

declare(strict_types=1);

namespace Acme\Faq\Controller\Index;

use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Framework\Controller\ResultInterface;

class Ping implements HttpGetActionInterface
{
    public function __construct(
        private readonly JsonFactory $jsonFactory
    ) {
    }

    public function execute(): ResultInterface
    {
        return $this->jsonFactory->create()->setData(['status' => 'ok']);
    }
}

// GET https://example.test/faq/index/ping returns 200 with
// Content-Type: application/json and the body {"status":"ok"}.
// No theme, no header, no layout.

Discussion

  • Be the first to comment on this lesson.