Front Controller Pattern: From index.php to the Controller
· Reading time: approx. 15 minutes · Category: Magento 2 · Architecture
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.
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
- 2. The bootstrap process: what happens before the FrontController
- 3. FrontController: implementation and dispatch loop
- 4. The five router types in Magento 2
- 5. Standard router: frontName, controller and action
- 6. URL rewrite router: resolving SEO URLs
- 7. Building your own router: implementing RouterInterface
- 8. Action classes: HttpGetActionInterface and friends
- 9. The RequestInterface: URL, parameters and more
- 10. Summary
- 11. FAQ
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.
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?
HttpGetActionInterface, contains the actual execution logic.2 Why does every request start at pub/index.php?
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?
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?
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?
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?
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?
8 How does routing work in the admin area?
/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?
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?
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.