MVC in Magento 2: Why It Is Not Classic MVC
· Reading time: approx. 14 minutes · Category: Magento 2 · Architecture
MVC in Magento 2:
Why It Is Not Classic MVC
Magento's architecture calls itself MVC, but it works in a fundamentally different way. Request flow, FrontController, layout XML as a fourth layer, block system and ViewModel: fully explained.
What Magento 2 Calls MVC, and What It Really Is
Every Magento documentation page starts somewhere with the sentence: "Magento 2 is based on the MVC pattern." That is true, and at the same time it isn't. Anyone who knows classic MVC (as implemented by Ruby on Rails, Laravel or Symfony) and then sees Magento code for the first time gets confused: Where are the views? Why are there XML files for the layout? What does this FrontController do? What is a Block?
The truth: Magento 2 took MVC as a starting point and extended it with several layers that don't exist in other frameworks. The result is an architecture that is closer to HMVC (Hierarchical MVC) or even an MVVM pattern with layout orchestration than to classic MVC.
This deep dive explains what really happens in Magento, from the arrival of the HTTP request to the HTML output, and why this architecture makes sense for an enterprise ecommerce system.
- 1. Classic MVC, a Quick Refresher
- 2. The Magento Request Flow: From index.php to the Response
- 3. FrontController and Router: Magento's Dispatcher
- 4. The Controller in Magento 2
- 5. Layout XML: The Fourth Layer That Breaks MVC
- 6. Block and Template: Magento's View Layer
- 7. The Model in Magento: EAV, Repository and More
- 8. ViewModel: From MVC to MVVM in the Hyva Theme
- 9. Complete Example Flow: Category Page
- 10. Summary
- 11. FAQ
1. Classic MVC, a Quick Refresher
Classic MVC, as designed by Trygve Reenskaug in 1979 and implemented by web frameworks like Rails or Symfony, splits an application into three clearly separated layers:
- Model: Holds the data and the business logic. Talks to the database. Knows neither the view nor the controller.
- View: Renders the data for the user. Knows the model (or at least its data), but knows no controller.
- Controller: Receives the request, fetches the model, and passes the data to the view. Controls the flow.
<?php
// Classic MVC in a simple framework (e.g. Laravel style):
// CONTROLLER: receives request, loads model, passes to view
class ProductController
{
public function show(int $id): Response
{
$product = Product::findOrFail($id); // Load model
return view('products.show', ['product' => $product]); // Render view
}
}
// MODEL: data and logic
class Product extends Model
{
protected $table = 'products';
public function getDiscountPrice(): float { ... }
}
// VIEW: resources/views/products/show.blade.php
// Direct data binding, no layer in between
// <h1>{{ $product->name }}</h1>
// <p>{{ $product->getDiscountPrice() }}</p>
That is clear, simple, and explainable in three lines. Magento does it differently, and for good reason.
2. The Magento Request Flow: From index.php to the Response
In Magento, every request starts at pub/index.php. What happens next is considerably more complex than in classic frameworks:
HTTP Request
↓
pub/index.php
↓ Bootstrap (autoloading, initialize DI container)
Magento\Framework\App\Bootstrap::run()
↓
Magento\Framework\App\Http (application object)
↓
FrontController::dispatch()
↓
Search router list (Standard, CMS, Admin, URL rewrite...)
↓
Matching controller action found
↓
ActionInterface::execute() → return ResultInterface
↓
Result::renderResult() → initialize layout
↓
Layout::loadXml() → merge all layout/*.xml files
↓
Layout::generateXml() → build block structure
↓
Layout::generateElements() → instantiate block objects
↓
Layout::getOutput() → call Block::toHtml() recursively
↓
HTTP Response with HTML
↓
Browser
Even in this overview you can see it: between the controller and the final HTML output sit several layers that don't exist in classic MVC: result objects, layout XML merging, block tree construction. That is the fundamental difference.
3. FrontController and Router: Magento's Dispatcher
The FrontController in Magento does not correspond to the "controller" in MVC. It is a dispatcher, responsible for forwarding the request to the right controller by searching through a list of routers.
<?php
// Simplified: what FrontController::dispatch() does
// vendor/magento/framework/App/FrontController.php
namespace Magento\Framework\App;
class FrontController implements FrontControllerInterface
{
public function __construct(
private readonly RouterListInterface $routerList
) {}
public function dispatch(RequestInterface $request): ResponseInterface
{
// Iterate through all registered routers
foreach ($this->routerList as $router) {
// Each router tries to match the request to an action
$action = $router->match($request);
if ($action instanceof ActionInterface) {
// Found! Execute the action.
$result = $action->execute();
// Result is NOT HTML yet, it's a ResultInterface object
// (ResultPage, ResultJson, ResultRedirect...)
if ($result instanceof ResultInterface) {
$result->renderResult($response);
}
return $response;
}
}
// No router matched, 404
throw new NotFoundException(__('Page not found.'));
}
}
By default, Magento registers the following routers in this order:
- Base Router: Checks admin routes (
adminhtml) - Standard Router: Matches
frontName/controller/action(e.g.catalog/product/view) - CMS Router: Matches CMS pages via URL key from the database
- URL Rewrite Router: Checks the
url_rewritetable for SEO URLs - Default Router: Renders the 404 page
4. The Controller in Magento 2
A Magento controller is a class that implements ActionInterface, more precisely, a class under Controller/ in a module that inherits from Magento\Framework\App\Action\Action. Its job is deliberately minimal: accept a request and return a result object.
<?php
declare(strict_types=1);
namespace Mironsoft\Blog\Controller\Post;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\View\Result\PageFactory;
use Magento\Framework\App\RequestInterface;
use Mironsoft\Blog\Api\PostRepositoryInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Controller\Result\RedirectFactory;
/**
* Controller action: renders a single blog post page.
*/
class View implements HttpGetActionInterface
{
public function __construct(
private readonly PageFactory $pageFactory,
private readonly RequestInterface $request,
private readonly PostRepositoryInterface $postRepository,
private readonly RedirectFactory $redirectFactory
) {}
/**
* Execute: validate post exists, return ResultPage.
* The controller does NOT render HTML, it returns a Result object.
*/
public function execute(): ResultInterface
{
$postId = (int) $this->request->getParam('id');
try {
$post = $this->postRepository->getById($postId);
} catch (NoSuchEntityException) {
// Return redirect result, controller decides the RESPONSE TYPE, not HTML
$redirect = $this->redirectFactory->create();
return $redirect->setPath('noroute');
}
// ResultPage tells Magento: render a full HTML page
// The actual content is defined by Layout-XML, NOT by the controller
$page = $this->pageFactory->create();
$page->getConfig()->getTitle()->set($post->getTitle());
return $page;
}
}
Key difference from classic MVC: The Magento controller does not return fully rendered HTML, it returns a ResultPage object. What gets displayed on the page is not decided by the controller, but by the layout XML. That is the fundamental difference.
5. Layout XML: The Fourth Layer That Breaks MVC
The layout XML system is the element in Magento's architecture that deviates most strongly from classic MVC. In other frameworks the controller decides which template gets rendered: return view('blog.post.show', $data). In Magento 2, the layout XML decides, an XML configuration layer that declaratively describes which blocks with which templates appear on which page.
<!-- app/code/Mironsoft/Blog/view/frontend/layout/mironsoft_blog_post_view.xml -->
<!-- Handle name = frontName_controller_action = mironsoft_blog_post_view -->
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"
layout="1column">
<body>
<!-- Add a block to the 'main' container -->
<referenceContainer name="main">
<block class="Magento\Framework\View\Element\Template"
name="mironsoft.blog.post.view"
template="Mironsoft_Blog::post/view.phtml">
<arguments>
<!-- Inject ViewModel, no controller code needed -->
<argument name="view_model" xsi:type="object">
Mironsoft\Blog\ViewModel\PostViewModel
</argument>
</arguments>
</block>
</referenceContainer>
<!-- Customize breadcrumbs -->
<referenceBlock name="breadcrumbs">
<action method="addCrumb">
<argument name="crumbName" xsi:type="string">blog</argument>
<argument name="crumbInfo" xsi:type="array">
<item name="label" xsi:type="string" translate="true">Blog</item>
<item name="link" xsi:type="string">/blog</item>
</argument>
</action>
</referenceBlock>
</body>
</page>
When building a page, Magento merges all layout XML files for the current handle. That lets multiple modules modify the same page without knowing about each other and without the controller needing to change. That is not something classic MVC provides for.
Layout handles: Every Magento page has several active layout handles at the same time: default (for all pages), catalog_product_view (for all product pages), and catalog_product_view_type_simple (only for simple products). All matching XML files get merged and produce the final block structure.
6. Block and Template: Magento's View Layer
In Magento's view layer, two components work together: block classes (PHP) and phtml templates (PHP/HTML mix). In modern Magento code, the ViewModel is added on top (more on that in the next section).
<?php
// BLOCK: binding to the layout system
// Inherits from Template, manages caching, child blocks, template assignment
namespace Magento\Catalog\Block\Product;
class View extends \Magento\Framework\View\Element\Template
{
/**
* Block knows its template (set via Layout-XML or constructor).
* It doesn't render itself, it provides data to the template.
*/
public function getProduct(): ProductInterface
{
// Data is NOT passed from the controller
// The Block fetches it itself via the registry or repository
return $this->coreRegistry->registry('current_product');
}
}
<?php
// TEMPLATE (phtml): the actual HTML output
// Available: $block (block object), $viewModel (ViewModel)
/** @var \Magento\Catalog\Block\Product\View $block */
$product = $block->getProduct();
?>
<div class="product-view">
<h1 class="product-name"><?= $block->escapeHtml($product->getName()) ?></h1>
<div class="product-price"><?= /* ... */ ?></div>
</div>
Unlike classic MVC: the template does not receive its data from the controller. The controller only returns a ResultPage object. The template fetches its data from the block, and the block fetches it from the registry, a repository, or other services.
7. The Model in Magento: EAV, Repository and More
The "model" in Magento is not a single class, it is a layered system of at least three components:
Magento model layer (not classic!):
┌─────────────────────────────────────────┐
│ Service Layer (Repository) │ ← Public API (Service Contracts)
│ PostRepositoryInterface::getById() │
├─────────────────────────────────────────┤
│ Model / Entity │ ← Data holder + data interface
│ Post extends AbstractModel │
├─────────────────────────────────────────┤
│ Resource Model (DB access) │ ← SQL, table name, primary key
│ ResourceModel\Post extends AbstractDb│
├─────────────────────────────────────────┤
│ Collection (bulk loading) │ ← Iterator over multiple entities
│ ResourceModel\Post\Collection │
└─────────────────────────────────────────┘
In classic MVC, the model is often a single class. In Magento there are at least four. The reason: separation of data persistence (resource model), data holding (model/entity), API contract (repository interface), and bulk operations (collection). That considerably increases testability and flexibility.
<?php
// In Magento: no direct DB call in the controller
// The controller does not know the model directly
// WRONG (classic MVC thinking):
public function execute(): ResultInterface
{
$product = $this->productModel->load($id); // Anti-pattern in Magento
// ...
}
// RIGHT (Magento Service Contracts):
public function execute(): ResultInterface
{
// Controller injects a repository (service layer), not the model directly
$product = $this->productRepository->getById($id);
// ...
}
8. ViewModel: From MVC to MVVM in the Hyva Theme
With the ViewModel pattern (available since Magento 2.2, standard in the Hyva Theme), Magento's architecture moves even closer to the MVVM pattern (Model View ViewModel). The ViewModel takes over the presentation logic from the block, and makes the view layer testable.
Magento architecture with ViewModel (Hyva):
HTTP Request
↓
FrontController → Router → Action/Controller
↓
ResultPage → Layout XML
↓
Block (rendering container)
│
├── ViewModel (presentation logic, testable, no Magento inheritance)
│ └── injected via Layout-XML
│
└── phtml template (HTML output)
↓
PHP data → JSON → Alpine.js (interactivity)
<?php
declare(strict_types=1);
namespace Mironsoft\Blog\ViewModel;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use Mironsoft\Blog\Api\PostRepositoryInterface;
use Mironsoft\Blog\Api\Data\PostInterface;
/**
* ViewModel: Provides presentation data for the blog post template.
* No Magento base class inheritance, easily unit-testable.
*/
class PostViewModel implements ArgumentInterface
{
public function __construct(
private readonly PostRepositoryInterface $postRepository
) {}
/**
* Returns the current post or null if not found.
*/
public function getPost(int $postId): ?PostInterface
{
try {
return $this->postRepository->getById($postId);
} catch (\Exception) {
return null;
}
}
/**
* Returns formatted publication date.
*/
public function formatPublishedAt(string $date): string
{
return (new \DateTimeImmutable($date))->format('d. F Y');
}
/**
* Returns reading time in minutes based on word count.
*/
public function getReadingTime(string $content): int
{
$wordCount = str_word_count(strip_tags($content));
return max(1, (int) ceil($wordCount / 200));
}
}
The result: the block is now only a thin rendering container. All logic lives in the testable ViewModel. The template receives data in a cleanly separated way. This is no longer classic MVC, this is MVVM with a layout orchestration layer underneath.
9. Complete Example Flow: Blog Post Page
Summary using a concrete example: a user opens /blog/post/view/id/42:
1. pub/index.php
Bootstrap: autoloading, DI container, ObjectManager
2. FrontController::dispatch()
Router list:
→ Standard Router matches "mironsoft_blog" / "post" / "view"
→ Action class: Mironsoft\Blog\Controller\Post\View
3. Mironsoft\Blog\Controller\Post\View::execute()
- Reads ?id=42 from the request
- Checks whether the post exists (Repository::getById(42))
- Throws NoSuchEntityException → redirect to 404
- Or: returns ResultPage (no HTML!)
4. ResultPage::renderResult()
- Initializes the layout system
- Active layout handles:
• "default" (all pages)
• "mironsoft_blog_post_view" (this specific page)
5. Layout XML merging
- Loads all layout/mironsoft_blog_post_view.xml files from all modules
- Merges with default.xml, 1column.xml
- Result: complete block tree in XML
6. Block instantiation
- DI container creates all block objects
- Injects the ViewModel as an argument
- Block tree: page > head, body > header, main, footer > blog.post.view
7. blog.post.view::toHtml()
→ Template: Mironsoft_Blog::post/view.phtml
→ $viewModel = $block->getData('view_model')
→ $post = $viewModel->getPost(42) ← database access here!
→ Generate and return HTML
8. HTTP Response
→ Browser renders HTML
This flow makes it clear: in classic MVC, the controller hands data to the view. In Magento, the view (block/ViewModel) fetches the data itself. The controller only defines the page type (ResultPage), the layout XML defines the structure, and the block/ViewModel fetches the data.
Mironsoft
Magento 2 Architecture & Development
Magento Architecture for Your Project?
We build Magento 2 modules and themes with clean architecture: Service Contracts, ViewModels, layout XML, and full PHPUnit tests, following current best practices.
10. Summary
Magento 2 is based on MVC, but extends it fundamentally. The classic triangle of model, view and controller is supplemented in Magento by a layout orchestration layer (XML), a block system, and the ViewModel pattern. The result is not a simple architecture, but an exceptionally flexible one for enterprise ecommerce.
MVC in Magento 2, Differences at a Glance
Controller
Returns ResultPage, not HTML. No direct template rendering. Decides the page type, not the content. Content comes from layout XML.
Layout XML (4th layer)
Defines which blocks appear on which page. Multiple modules can extend the same page. Not a classic MVC concept.
Block + Template
Block = rendering container (Magento base class). Template = phtml file (HTML + PHP). The view fetches its own data via block/ViewModel, not from the controller.
Model (Service Contracts)
Not one class, but 4 layers: repository interface → repository implementation → model → resource model. Service Contracts as a stable outward API.
11. FAQ: MVC in Magento 2
1 Biggest difference: Magento vs. classic MVC?
ResultPage object. What appears on the page is determined by the layout XML. The view layer (block/ViewModel) fetches data itself from repositories.2 What does the FrontController do in Magento 2?
3 What is a layout handle in Magento 2?
default, catalog_product_view, catalog_product_view_type_simple. All matching XMLs get merged → final block structure.4 Why doesn't the controller return a template?
layout/handle.xml files. That is not something classic MVC provides for.5 What is the difference between Block and Template?
6 How does Magento process an HTTP request?
pub/index.php → bootstrap. 2. FrontController → router search. 3. Action class found. 4. execute() → ResultPage. 5. Load & merge layout XML. 6. Build block tree. 7. toHtml() recursively → HTML. 8. HTTP response.7 What is the difference between Area and Store Scope?
frontend, adminhtml, crontab). Its own di.xml, layout XML, and observers per area. Store Scope: business configuration level (website → store → store view). Independent of each other.