Front Controller Pattern in Magento 2: From index.php to the Controller | Mironsoft
AI generated

Front Controller Pattern: From index.php to the Controller

· Reading time: approx. 15 minutes · Category: Magento 2 · Architecture

HTTP
Router
Magento 2 · Deep Dive · Routing

Front Controller Pattern:
From index.php to the Controller

How does Magento 2 process every HTTP request? Bootstrap, FrontController, router chain, URL rewriting and action classes, the complete path fully explained.

⏱ 15 min Deep Dive Routing PHP 8.4

The gateway to every Magento page

Every HTTP request to a Magento shop, whether a product page, checkout or a REST API call, begins at exactly one place: pub/index.php. From there, Magento decides within milliseconds which code will run for this request. The heart of this mechanism is the Front Controller Pattern.

The Front Controller Pattern is not a Magento-specific concept. It comes from the GoF design patterns (Gang of Four, 2002) and describes a single entry point that centralizes all incoming requests before forwarding them to the responsible handlers. Many frameworks implement it, but Magento makes it particularly complex because a multi-stage router system sits between the FrontController and the actual controller action.

This deep dive explains the complete path: from the first byte of the HTTP request to the instantiation of the action class, and how you extend this system with your own routers.

1. The Front Controller Pattern: theory and motivation

The Front Controller Pattern addresses a central problem in web applications: without a central entry point, every PHP file in the web root would need to contain its own bootstrap logic, security checks and routing code. That leads to massive code duplication.

The solution: a single entry point for all requests. This entry point takes care of all cross-cutting concerns and then delegates to specialized handlers.


WITHOUT the Front Controller Pattern (anti-pattern):
  /catalog/product.php   ← own bootstrap, own auth check
  /checkout/cart.php     ← own bootstrap, own auth check
  /customer/account.php  ← own bootstrap, own auth check
  /api/products.php      ← own bootstrap, own auth check

WITH the Front Controller Pattern (Magento's approach):
  pub/index.php           ← SINGLE entry point
      ↓
  FrontController         ← cross-cutting concerns
      │ ├── security checks
      │ ├── session management
      │ ├── area determination (frontend / adminhtml / api)
      │ └── CSRF protection
      ↓
  Router chain            ← routing
      ↓
  Concrete handler        ← business logic (only one handler, no duplication)

Magento adds another advantage: the FrontController is registered in the DI container and can be extended via plugins. You can add request preprocessing or response postprocessing without touching pub/index.php.

2. The bootstrap process: what happens before the FrontController

Before the FrontController executes even a single line of code, Magento runs through an extensive bootstrap process. pub/index.php is merely the trigger:


<?php
// pub/index.php (simplified, the original has about 30 lines)
declare(strict_types=1);

use Magento\Framework\App\Bootstrap;

// 1. Initialize the autoloader (Composer)
require __DIR__ . '/../app/bootstrap.php';

// 2. Create the bootstrap object
//    Reads: BP (base path), MAGE_MODE, MAGE_PROFILER
$bootstrap = Bootstrap::create(BP, $_SERVER);

// 3. Create and run the application object
//    For HTTP requests: Magento\Framework\App\Http
$app = $bootstrap->createApplication(\Magento\Framework\App\Http::class);

// 4. run() kicks off the entire request lifecycle
$bootstrap->run($app);

Bootstrap sequence (what Bootstrap::run() triggers):

pub/index.php
    ↓
Bootstrap::create()
    ├── load the Composer autoloader
    ├── read environment variables (MAGE_MODE, etc.)
    └── initialize the ObjectManager (DI container)
            ↓
        load DI configuration (merge di.xml from all modules)
        register preferences and plugins

Bootstrap::run(Http::class)
    ↓
Http::launch()
    ├── load area configuration (frontend/adminhtml/...)
    ├── provide the response object
    └── call FrontController::dispatch($request)

Performance note: merging all di.xml files during bootstrap is the most expensive operation at Magento startup. In production mode the result is cached in var/di/. In developer mode it happens on every request, which is why developer mode is noticeably slower.

3. FrontController: implementation and dispatch loop

The FrontControllerInterface is minimal: a single method dispatch(RequestInterface $request): ResponseInterface. The implementation in Magento\Framework\App\FrontController contains the router loop:


<?php
declare(strict_types=1);

// vendor/magento/framework/App/FrontController.php (simplified)
namespace Magento\Framework\App;

use Magento\Framework\App\Response\Http as HttpResponse;
use Magento\Framework\Controller\ResultInterface;

class FrontController implements FrontControllerInterface
{
    public function __construct(
        private readonly RouterListInterface $routerList,
        private readonly HttpResponse $response
    ) {}

    /**
     * Dispatch the request through the router chain.
     * Returns HTTP response (HTML, JSON, redirect, etc.).
     */
    public function dispatch(RequestInterface $request): ResponseInterface
    {
        $validCounter = 0;
        $allowedLoop  = 100; // safeguard against infinite redirect loops

        do {
            // Reset routing state for each loop iteration
            $request->setDispatched(false);

            // Walk through ALL registered routers in sortOrder sequence
            foreach ($this->routerList as $router) {
                // Each router tries to match the current request path
                /** @var ActionInterface|null $action */
                $action = $router->match($request);

                if ($action === null) {
                    // This router did not match, try next router
                    continue;
                }

                // Match found, execute the action
                $result = $action->execute();

                // Mark request as dispatched so the loop exits
                $request->setDispatched(true);

                if ($result instanceof ResultInterface) {
                    // Result renders itself into the response object
                    $result->renderResult($this->response);
                }

                break; // stop iterating routers
            }

            ++$validCounter;
        } while (!$request->isDispatched() && $validCounter < $allowedLoop);

        return $this->response;
    }
}

Important: the do-while loop allows internal redirects. A router can modify the request (e.g. rewrite the URL to a different path) and set setDispatched(false) to restart the router chain. That is the mechanism behind URL rewrites.

4. The five router types in Magento 2

By default, Magento registers five routers that are searched in the order of their sortOrder. The first router that produces a match wins:


<!-- vendor/magento/module-store/etc/di.xml (simplified) -->
<type name="Magento\Framework\App\RouterList">
    <arguments>
        <argument name="routerList" xsi:type="array">
            <!-- sortOrder determines the order -->
            <item name="admin" xsi:type="array">
                <item name="class" xsi:type="string">Magento\Backend\App\Router\DefaultRouter</item>
                <item name="disable" xsi:type="boolean">false</item>
                <item name="sortOrder" xsi:type="string">1</item>
            </item>
            <item name="robots" xsi:type="array">
                <item name="class" xsi:type="string">Magento\Robots\Controller\Router</item>
                <item name="sortOrder" xsi:type="string">10</item>
            </item>
            <item name="urlrewrite" xsi:type="array">
                <item name="class" xsi:type="string">Magento\UrlRewrite\Controller\Router</item>
                <item name="sortOrder" xsi:type="string">20</item>
            </item>
            <item name="standard" xsi:type="array">
                <item name="class" xsi:type="string">Magento\Framework\App\Router\Base</item>
                <item name="sortOrder" xsi:type="string">30</item>
            </item>
            <item name="cms" xsi:type="array">
                <item name="class" xsi:type="string">Magento\Cms\Controller\Router</item>
                <item name="sortOrder" xsi:type="string">60</item>
            </item>
            <item name="default" xsi:type="array">
                <item name="class" xsi:type="string">Magento\Framework\App\Router\DefaultRouter</item>
                <item name="sortOrder" xsi:type="string">100</item>
            </item>
        </argument>
    </arguments>
</type>
Router sortOrder Matches
Admin Router 1 Admin URLs (/admin/...), checks the configured admin frontname
URL Rewrite Router 20 SEO URLs from the url_rewrite table (product, category, CMS URLs)
Standard Router 30 Technical URLs in the format /frontName/controller/action
CMS Router 60 CMS pages by URL key from the database
Default Router 100 Fallback, always renders the 404 page

5. Standard router: frontName, controller and action

The standard router is the most important router for custom modules. It matches URLs in the format /frontName/controller/action and maps them to classes under Controller/ in the module directory.


<!-- app/code/Mironsoft/Blog/etc/frontend/routes.xml -->
<!-- Registers the frontName for this module with the standard router -->
<?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="standard">
        <route id="mironsoft_blog" frontName="blog">
            <module name="Mironsoft_Blog"/>
        </route>
    </router>
</config>

With this configuration, the standard router matches every URL that starts with /blog/. The mapping from URL to class follows a strict convention:


URL-to-class mapping (standard router):

URL: /blog/post/view
     │    │     │
     │    │     └── Action:     Controller/Post/View.php
     │    └──────── Controller: Controller/Post/
     └───────────── frontName:  routes.xml (frontName="blog")

Full class name: Mironsoft\Blog\Controller\Post\View

URL: /blog/post/index  → Mironsoft\Blog\Controller\Post\Index
URL: /blog/index/index → Mironsoft\Blog\Controller\Index\Index
URL: /blog/            → Mironsoft\Blog\Controller\Index\Index (defaults)

Defaults:
  - no controller in the URL → "Index"
  - no action in the URL     → "Index"

<?php
// What the standard router does internally (simplified):
// vendor/magento/framework/App/Router/Base.php

namespace Magento\Framework\App\Router;

class Base implements RouterInterface
{
    /**
     * Try to match the request to a module action class.
     */
    public function match(RequestInterface $request): ?ActionInterface
    {
        // Parse URL path: /frontName/controllerPath/actionName
        $pathParts = explode('/', trim($request->getPathInfo(), '/'));

        $frontName      = $pathParts[0] ?? 'index';
        $controllerPath = $pathParts[1] ?? 'index';
        $actionName     = $pathParts[2] ?? 'index';

        // Find module by frontName (from routes.xml registry)
        $module = $this->routeConfig->getModulesByFrontName($frontName);
        if (empty($module)) {
            return null; // This router does not handle this URL
        }

        // Build class name: Vendor\Module\Controller\ControllerPath\ActionName
        $actionClassName = $this->actionList->get($module, $controllerPath, $actionName);

        if (!$actionClassName || !class_exists($actionClassName)) {
            return null;
        }

        // Set routing parameters on the request for later use
        $request->setModuleName($module);
        $request->setControllerName($controllerPath);
        $request->setActionName($actionName);

        // Instantiate and return the action via DI
        return $this->objectManager->create($actionClassName);
    }
}

6. URL rewrite router: resolving SEO URLs

Product pages in Magento have URLs like /red-t-shirt-xl.html instead of /catalog/product/view/id/42. This translation is handled by the URL rewrite router, which runs before the standard router (sortOrder 20 vs. 30).


URL rewrite mechanism:

Browser request: GET /red-t-shirt-xl.html

URL rewrite router (sortOrder 20):
    ↓
SELECT * FROM url_rewrite WHERE request_path = 'red-t-shirt-xl.html'
    ↓
Result: { target_path: 'catalog/product/view/id/42', redirect_code: 0 }
    ↓
If redirect_code = 301 or 302:
    → return an HTTP redirect (external URL change)

If redirect_code = 0 (internal rewrite):
    → set the request path to 'catalog/product/view/id/42'
    → setDispatched(false) → FrontController restarts the router loop
    → the standard router now matches: catalog/product/view → Catalog\Product\View

Result for the browser: URL stays /red-t-shirt-xl.html
                         Internally, catalog/product/view/id/42 is executed

<?php
// Simplified: Magento\UrlRewrite\Controller\Router::match()
namespace Magento\UrlRewrite\Controller;

use Magento\UrlRewrite\Service\V1\Data\UrlRewrite;

class Router implements RouterInterface
{
    /**
     * Look up the request path in url_rewrite table.
     * Modifies request for internal rewrites; returns redirect action for external ones.
     */
    public function match(RequestInterface $request): ?ActionInterface
    {
        // Current URL path (e.g. "red-t-shirt-xl.html")
        $requestPath = ltrim($request->getPathInfo(), '/');

        // Database lookup
        $rewrite = $this->urlFinder->findOneByData([
            UrlRewrite::REQUEST_PATH => $requestPath,
            UrlRewrite::STORE_ID     => $this->storeManager->getStore()->getId(),
        ]);

        if ($rewrite === null) {
            return null; // No rewrite found, next router's turn
        }

        // External redirect (301/302)
        if ($rewrite->getRedirectType() > 0) {
            return $this->getRedirectAction($rewrite);
        }

        // Internal rewrite, silently change the path and re-dispatch
        $request->setPathInfo('/' . $rewrite->getTargetPath());
        $request->setAlias(Url::REWRITE_REQUEST_PATH_ALIAS, $requestPath);

        // Trigger re-dispatch loop in FrontController
        $request->setDispatched(false);

        return null; // No action to return, router loop will handle the new path
    }
}

Important: after an internal URL rewrite, the router returns null (no action object) and sets setDispatched(false). The do-while loop in the FrontController then restarts the router chain, this time with the new, internal path. The browser sees none of this.

7. Building your own router: implementing RouterInterface

For special URL structures, such as vanity URLs, API proxies or parameter-rich permalinks, you can register your own router. The interface is simple:


<?php
declare(strict_types=1);

namespace Mironsoft\Blog\Controller;

use Magento\Framework\App\ActionFactory;
use Magento\Framework\App\ActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\App\RouterInterface;
use Magento\Framework\App\Action\Forward;

/**
 * Custom router: resolves /blog/<slug> URLs to the blog post view action.
 * Handles slugs that don't follow the standard frontName/controller/action pattern.
 */
class Router implements RouterInterface
{
    public function __construct(
        private readonly ActionFactory $actionFactory,
        private readonly PostRepositoryInterface $postRepository
    ) {}

    /**
     * Match /blog/<slug> pattern and forward to the post view action.
     */
    public function match(RequestInterface $request): ?ActionInterface
    {
        $identifier = trim($request->getPathInfo(), '/');

        // Only handle paths that start with "blog/" but have no further segments
        // e.g. /blog/my-first-post  (not /blog/post/view)
        if (!preg_match('#^blog/([a-z0-9-]+)$#', $identifier, $matches)) {
            return null;
        }

        $slug = $matches[1];

        // Verify the slug exists
        $post = $this->postRepository->getBySlug($slug);
        if ($post === null) {
            return null; // Pass to next router (CMS, Default/404)
        }

        // Forward to existing action, no redirect, URL stays clean
        $request->setModuleName('mironsoft_blog');
        $request->setControllerName('post');
        $request->setActionName('view');
        $request->setParam('id', $post->getId());
        $request->setDispatched(true);

        // ActionFactory creates an internal forward (re-runs action without HTTP redirect)
        return $this->actionFactory->create(Forward::class);
    }
}

<!-- app/code/Mironsoft/Blog/etc/di.xml -->
<!-- Register the router in the DI container -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Framework\App\RouterList">
        <arguments>
            <argument name="routerList" xsi:type="array">
                <item name="mironsoft_blog" xsi:type="array">
                    <item name="class" xsi:type="string">Mironsoft\Blog\Controller\Router</item>
                    <item name="disable" xsi:type="boolean">false</item>
                    <!-- sortOrder 25: after URL rewrite (20), before the standard router (30) -->
                    <item name="sortOrder" xsi:type="string">25</item>
                </item>
            </argument>
        </arguments>
    </type>
</config>

The choice of sortOrder is critical. Custom routers should sit after the URL rewrite router (20) and before the standard router (30), so they can intercept specific URLs before the generic fallback takes over.

8. Action classes: HttpGetActionInterface and friends

Since Magento 2.3 there are HTTP method interfaces that are preferable to the older Action base class. They let Magento automatically reject incorrect HTTP methods (e.g. POST on a GET-only route) with 405 Method Not Allowed:


<?php
declare(strict_types=1);

namespace Mironsoft\Blog\Controller\Post;

use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\View\Result\PageFactory;
use Mironsoft\Blog\Api\PostRepositoryInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Controller\Result\RedirectFactory;

/**
 * GET action: displays a single blog post.
 * Only responds to HTTP GET, POST/PUT/DELETE get 405 automatically.
 */
class View implements HttpGetActionInterface
{
    public function __construct(
        private readonly PageFactory $pageFactory,
        private readonly RequestInterface $request,
        private readonly PostRepositoryInterface $postRepository,
        private readonly RedirectFactory $redirectFactory
    ) {}

    public function execute(): ResultInterface
    {
        $postId = (int) $this->request->getParam('id');

        try {
            $this->postRepository->getById($postId);
        } catch (NoSuchEntityException) {
            return $this->redirectFactory->create()->setPath('noroute');
        }

        $page = $this->pageFactory->create();
        $page->getConfig()->getTitle()->set(__('Blog'));

        return $page;
    }
}

<?php
declare(strict_types=1);

namespace Mironsoft\Blog\Controller\Post;

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\ResultInterface;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Framework\App\Action\Context;

/**
 * POST action: saves a blog comment.
 * Implements CsrfAwareActionInterface for CSRF token validation.
 */
class Comment implements HttpPostActionInterface, CsrfAwareActionInterface
{
    public function __construct(
        private readonly JsonFactory $jsonFactory,
        private readonly RequestInterface $request
    ) {}

    /**
     * Validate CSRF token, called automatically by Magento before execute().
     */
    public function validateForCsrf(RequestInterface $request): ?bool
    {
        // Return null to use Magento's default CSRF validation
        return null;
    }

    /**
     * Create CSRF validation exception, called when CSRF validation fails.
     */
    public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException
    {
        $response = $this->jsonFactory->create();
        $response->setData(['error' => true, 'message' => 'Invalid form key.']);

        return new InvalidRequestException($response);
    }

    public function execute(): ResultInterface
    {
        $result = $this->jsonFactory->create();
        // ... process comment
        return $result->setData(['success' => true]);
    }
}
Interface HTTP method Typical use
HttpGetActionInterface GET Displaying pages, loading data
HttpPostActionInterface POST Processing forms, saving data
HttpPutActionInterface PUT REST updates (rare in the frontend)
HttpDeleteActionInterface DELETE REST delete operations

9. The RequestInterface: URL, parameters and more

The RequestInterface object is the central data store for all request information. It is available throughout the entire request lifecycle and is populated with routing information by the router:


<?php
declare(strict_types=1);

namespace Mironsoft\Blog\Controller\Post;

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

/**
 * Demonstrates RequestInterface methods available in actions.
 */
class Demo implements HttpGetActionInterface
{
    public function __construct(
        private readonly RequestInterface $request,
        private readonly PageFactory $pageFactory
    ) {}

    public function execute(): ResultInterface
    {
        // --- URL information ---
        $pathInfo    = $this->request->getPathInfo();     // "/blog/post/view"
        $moduleName  = $this->request->getModuleName();   // "mironsoft_blog"
        $controller  = $this->request->getControllerName(); // "post"
        $action      = $this->request->getActionName();   // "view"

        // --- Query parameters (?id=42&tab=comments) ---
        $id  = (int) $this->request->getParam('id');
        $tab = $this->request->getParam('tab', 'default'); // with a default value

        // --- POST data ---
        $postData = $this->request->getPost();         // all POST values as an array
        $email    = $this->request->getPost('email');  // a single POST value

        // --- HTTP method ---
        $method  = $this->request->getMethod();   // "GET", "POST", etc.
        $isAjax  = $this->request->isAjax();      // X-Requested-With: XMLHttpRequest

        // --- Headers ---
        $accept  = $this->request->getHeader('Accept');
        $referer = $this->request->getHeader('Referer');

        // --- Routing status ---
        $dispatched = $this->request->isDispatched(); // true during execution

        return $this->pageFactory->create();
    }
}

Security note: getParam() returns both GET and POST parameters. For POST forms always use getPost() and additionally implement CsrfAwareActionInterface. All values from getParam() are unsanitized, always output them in templates through $block->escapeHtml().

Mironsoft

Magento 2 Routing & Architecture

Need to solve routing problems in Magento?

We analyze and extend Magento routing: custom routers for complex URL structures, URL rewrite optimization and complete router diagnostics, following current best practices.

Router analysis
Reviewing existing routing configuration: diagnosing conflicts between routers, incorrect sortOrder values and unreachable actions.
Custom routers
Building custom routers for specific URL patterns: slug-based URLs, vanity URLs, API proxies and parameter-rich permalinks.
Request optimization
Identifying performance bottlenecks in the request flow: bootstrap optimization, reducing router overhead, tuning the URL rewrite cache.

10. Summary

The Front Controller Pattern in Magento 2 is a multi-stage system: pub/index.php triggers the bootstrap, the bootstrap creates the HTTP application object, which calls FrontController::dispatch(). This runs a do-while loop through a sorted list of routers. The first router that matches returns an action class, which runs execute() and returns a result object.

Front Controller Pattern, overview

Bootstrap

pub/index.php initializes the Composer autoloader and the DI container. Cached in var/di/ in production mode. The most expensive phase, once per request.

Router chain

Admin (1) → URL rewrite (20) → Standard (30) → CMS (60) → Default/404 (100). Hook in custom routers via di.xml with a chosen sortOrder.

URL rewrite

Translates SEO URLs (/red-shirt.html) into technical paths (catalog/product/view/id/42). Internal rewrite: the URL stays, the path changes. External rewrite: HTTP 301/302.

Action classes

HttpGetActionInterface instead of the old Action base class. Magento validates the HTTP method automatically, wrong methods result in 405. CsrfAwareActionInterface for POST forms.

11. FAQ: Front Controller Pattern in Magento 2

1 FrontController vs. a normal controller: what's the difference?
FrontController: a dispatcher that receives all HTTP requests and searches the router chain for the responsible handler. No business logic. Normal controller: the concrete request handler for a specific URL, implements HttpGetActionInterface, contains the actual execution logic.
2 Why does every request start at pub/index.php?
Only pub/ is configured as the web root. Nginx/Apache forwards every request that doesn't hit a static file to pub/index.php via try_files. That's the Front Controller Pattern: a single entry point for all dynamic requests.
3 In what order are Magento routers searched?
Admin (1) → URL rewrite (20) → Standard (30) → CMS (60) → Default/404 (100). Insert custom routers via di.xml with a chosen sortOrder. sortOrder 25 is the typical spot for custom routers (after URL rewrite, before standard).
4 How do you register your own router?
1. Create a class with RouterInterface (a match() method). 2. Register it in etc/di.xml under the Magento\Framework\App\RouterList type, as an item with class, disable=false and sortOrder. Then: setup:di:compile plus a cache flush.
5 HttpGetActionInterface vs. the old Action base class?
The old Action base class responded to all HTTP methods. HttpGetActionInterface restricts it to GET, a POST request automatically results in 405 Method Not Allowed with no manual code. Improves security and REST compliance.
6 How do you debug the routing status in Magento?
Xdebug breakpoint in FrontController::dispatch() · a plugin on RouterInterface::match() for logging · bin/magento dev:profiler:enable with the HTML profiler · the query log for url_rewrite lookups · developer mode for detailed error messages.
7 Why do both the URL rewrite router AND the standard router exist?
Standard router: matches technical URLs directly, no DB lookup, very fast. URL rewrite router: translates SEO URLs via a DB lookup. The separation makes it possible to change SEO URLs in the admin panel without touching code. Technical URLs also work without any URL rewrite entries.
8 How does routing work in the admin area?
The admin router (sortOrder 1) checks the configured admin frontname (default: /admin/, changeable in etc/env.php). Register admin actions in etc/adminhtml/routes.xml. Admin actions inherit from Magento\Backend\App\Action, including auth plus ACL checks.
9 What happens if no router matches?
The default router (sortOrder 100) always matches, it delivers the NoRoute/404 action. The do-while loop in the FrontController relies on this: without a default router there would be an infinite loop. The 404 page is configurable via noroute_index_index.xml.
10 How do you test a custom router with PHPUnit?
In a unit test: mock RequestInterface, set getPathInfo() to the test path, mock the repository. Then call router->match($request), check whether it returns null (no match) or an ActionInterface. For integration tests: Magento\TestFramework\Request with real DB fixtures.