Table of contents
- Classic MVC vs. Magento 2 MVC
- The complete request flow: index.php to HTML
- FrontController::dispatch() in detail
- Router types and their sortOrder
- Creating a custom route with routes.xml
- Controller actions in Magento 2.4.8
- The layout handle system
- ViewModel vs. Block: why ViewModel is better
- Summary
- FAQ
Classic MVC vs. Magento 2 MVC
The Model-View-Controller pattern (MVC) is one of the most fundamental architectural patterns in software development. It separates an application into three layers: the Model holds data and business logic, the Controller coordinates the interaction between Model and View, and the View is responsible for presentation. In classic web frameworks the controller loads data from the model and hands it directly to the view layer. Laravel or Symfony follow this pattern very directly: return view('template', ['products' => $products]).
Magento 2 implements MVC, but in a fundamentally different way. The key difference: a Magento 2 controller does not pass data to a view. Instead, it returns a ResultInterface object that describes what kind of response should be rendered, typically an HTML page, a JSON response, or a redirect. The data for the HTML page does not come from the controller, but from Block classes or ViewModels configured in the layout XML.
This deliberate decoupling has an important reason: Magento 2 needs to let third-party modules add or change page content without modifying controller code. A module that wants to add a banner to the product detail page must not need to change the core controller. The layout XML system makes it possible to declaratively define page content and extend it modularly, without touching the controller code.
The layers in the Magento 2 MVC are therefore more sharply separated than in classic frameworks. The controller is deliberately thin, at best it sets a page title and returns a page result object. The view layer is three-tiered: layout XML defines the structure, Blocks or ViewModels supply the data, and PHTML templates render the HTML. The Front Controller pattern connects these layers through a hierarchical router system.
The complete request flow: index.php to HTML
When a browser sends an HTTP request to a Magento 2 shop, the journey begins at pub/index.php. This file is the only publicly reachable PHP entry point, all other PHP files live outside the web root. This is the Front Controller pattern in its purest form: a single entry point for all requests. pub/index.php initializes the bootstrap process, which builds the Magento autoloader, the ObjectManager and the DI configuration.
The bootstrap calls Magento\Framework\App\Http::launch(), which starts the actual request processing. Http creates a Request object from the PHP superglobals ($_SERVER, $_GET, $_POST, $_COOKIE) and passes it to FrontController::dispatch(). From here the Front Controller takes over and iterates all registered routers.
Each router implements RouterInterface with a match(RequestInterface $request) method. This method either returns an action class (if the router matches the request) or null (if it cannot process the request). The Front Controller calls the routers in the order of their sortOrder and stops at the first successful match. The action class implements ActionInterface with an execute() method.
execute() returns a ResultInterface object. For HTML pages this is a Page object containing the layout configuration. The layout system reads the relevant layout XML files, builds the block tree, calls the template rendering methods and writes the final HTML into the HTTP response. This response is then returned to the web server and sent to the browser.
FrontController::dispatch() in detail
The FrontController::dispatch() method is the heart of Magento 2 request processing. It receives a RequestInterface object and returns a ResponseInterface object. Internally it iterates over all registered router classes in the configured order. For each router, match($request) is called. If the router returns an action instance, execute() is called on the action and the result is processed.
<?php
// Simplified representation of FrontController::dispatch()
// Original class: Magento\Framework\App\FrontController
namespace Magento\Framework\App;
use Magento\Framework\App\Request\Http as HttpRequest;
use Magento\Framework\App\Response\Http as HttpResponse;
class FrontController implements FrontControllerInterface
{
public function __construct(
private readonly RouterList $routerList,
private readonly HttpResponse $response
) {}
/**
* Dispatch request through router chain (Front Controller pattern).
*/
public function dispatch(RequestInterface $request): ResponseInterface
{
// Iterate routers in sortOrder sequence
foreach ($this->routerList as $router) {
// Each router either matches the request or returns null
/** @var ActionInterface|null $actionInstance */
$actionInstance = $router->match($request);
if ($actionInstance instanceof ActionInterface) {
// Action found: execute it
$result = $actionInstance->execute();
// Process and render result into response
if ($result instanceof ResultInterface) {
$result->renderResult($this->response);
return $this->response;
}
}
}
// No router matched, 404 Not Found
return $this->response->setHttpResponseCode(404);
}
}
An important aspect of dispatch() is how it handles forward requests. When an action calls forward(), the router iteration starts over with a modified request object. This enables internal request forwarding without an HTTP redirect. A typical example: an action checks whether a user is logged in, and forwards to the login page otherwise, without the browser ever seeing a redirect.
Router types and their sortOrder
Magento 2 has several standard routers that are called by the Front Controller in a defined order (sortOrder). The order is decisive: the router that matches first "wins" and the request is not passed on to further routers. The routers are configured as an array in the global di.xml of the Magento framework.
The Base Router (sortOrder 20) processes all standard frontend URLs following the pattern frontName/controllerFolder/actionName. It reads all registered routes.xml files and looks for a matching frontName. When a match is found, the Base Router determines the controller class from the module name, the controller folder and the action name. It creates an instance of this class and returns it.
The CMS Router (sortOrder 60) is called when the Base Router finds no match. It searches the CMS page table for an entry with the matching URL key. When a CMS page is found, the CMS Router forwards the request to the Magento\Cms\Controller\Page\View action. The CMS Router makes it possible to manage pages through the CMS backend without explicit routes.xml entries.
The URL Rewrite Router processes rewritten URLs from the url_rewrite table. Categories, products and CMS pages typically have SEO friendly URLs such as /women/shoes, which are internally redirected to /catalog/category/view/id/42. The URL Rewrite Router consults this table and modifies the request accordingly, before delegating the URL resolution to the Base Router. The Default Router (sortOrder 100) is the fallback router and renders the 404 page.
Creating a custom route with routes.xml
To register a custom frontend URL such as /blog/post/view in Magento 2.4.8, you need a routes.xml file in the etc/frontend/ directory of the module. This file registers a frontName (the first part of the URL) with the Base Router and links it to the module name. The Base Router then uses the module name to determine the namespace for the controller classes.
<?xml version="1.0"?>
<!-- app/code/Mironsoft/Blog/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">
<!-- frontName: first URL segment (/blog/...) -->
<route id="mironsoft_blog" frontName="blog">
<module name="Mironsoft_Blog"/>
</route>
</router>
</config>
The frontName is the first part of the URL matched by the Base Router. The id of the route is the handle prefix for the layout system. With this configuration the router matches all URLs starting with /blog/ and forwards them to the Mironsoft_Blog module. The second URL segment determines the controller folder (e.g. Post for /blog/post/...), the third segment the action name (e.g. View for /blog/post/view).
For admin routes there is an equivalent etc/adminhtml/routes.xml file with <router id="admin">. Admin URLs automatically use the configured admin path (typically /admin/) as a prefix. Admin controllers additionally have to check ACL permissions, because the admin area must not be accessible without authentication.
Controller actions in Magento 2.4.8
In Magento 2 every action is its own PHP class, not a method within a controller class as in classic MVC frameworks. Blog/Index.php and Blog/View.php are two separate classes. This follows the Single Responsibility Principle: each action class is responsible for exactly one URL action. This architecture makes it easier to extend individual actions (through plugins) without affecting other actions.
<?php
declare(strict_types=1);
namespace Mironsoft\Blog\Controller\Post;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\View\Result\Page;
use Magento\Framework\View\Result\PageFactory;
use Magento\Framework\Controller\Result\RedirectFactory;
use Mironsoft\Blog\Api\PostRepositoryInterface;
/**
* Blog post view action, renders a single blog post.
*/
class View implements HttpGetActionInterface
{
public function __construct(
private readonly PageFactory $pageFactory,
private readonly RedirectFactory $redirectFactory,
private readonly PostRepositoryInterface $postRepository,
private readonly RequestInterface $request
) {}
/**
* Execute action, render blog post or redirect on error.
*/
public function execute(): Page|\Magento\Framework\Controller\Result\Redirect
{
$postId = (int) $this->request->getParam('id');
if (!$postId) {
return $this->redirectFactory->create()->setPath('blog');
}
try {
$post = $this->postRepository->getById($postId);
} catch (NoSuchEntityException) {
return $this->redirectFactory->create()->setPath('blog')->setHttpResponseCode(302);
}
$page = $this->pageFactory->create();
$page->getConfig()->getTitle()->set($post->getTitle());
// Make post data available to layout blocks/ViewModels via registry alternative
// In Magento 2.4.8: use ViewModel + Repository, not registry
return $page;
}
}
Since Magento 2.4, using HttpGetActionInterface or HttpPostActionInterface instead of AbstractAction is the recommended approach. These interfaces signal to the framework which HTTP methods the action accepts. If a GET action is called with a POST request, the framework automatically returns a 405 Method Not Allowed. This is safer and clearer than the old way, where AbstractAction::execute() was called for every HTTP method.
The layout handle system
The layout handle system is the heart of the view layer in Magento 2 and the biggest conceptual difference from classic MVC frameworks. A layout handle is a string that determines which layout XML files are loaded for a given page. The handle is composed from the route: {routeId}_{controllerFolder}_{actionName}, always in lowercase.
For the URL /blog/post/view with route ID mironsoft_blog, the resulting handle is mironsoft_blog_post_view. The layout system looks for a file view/frontend/layout/mironsoft_blog_post_view.xml in the module. In addition, global handles such as default and page-specific handles such as catalog_product_view are always loaded. This hierarchy makes it possible to define elements globally (via default) or page-specifically.