MVC in Magento 2: Why It Is Not Classic MVC (Architecture Explained) | Mironsoft
AI generated

MVC in Magento 2: Why It Is Not Classic MVC

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

MVC
Layout
Magento 2 · Deep Dive · 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.

⏱ 14 min. Deep Dive Architecture PHP 8.4

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

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_rewrite table 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.

Architecture Review
Auditing existing Magento modules for architecture problems: anti-patterns, ObjectManager calls, missing Service Contracts.
Module Development
New Magento 2 modules with a clean MVC/MVVM approach: controller, layout XML, ViewModel, Service Contracts.
Hyva Migration
Migrating Luma templates to the Hyva Theme: block logic moved into ViewModels, Alpine.js for interactivity, Tailwind CSS.

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?
In classic MVC, the controller hands data to the view. In Magento, the controller gives no data, it returns a 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?
Not a classic controller, a dispatcher. It searches through a list of routers (standard, CMS, URL rewrite...) to find the matching action class. Corresponds to the GoF Front Controller Pattern, a single entry point for all requests.
3 What is a layout handle in Magento 2?
An ID that determines which layout XML files are loaded. Every page has several active handles: 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?
Intentional: separation of "which page type" (controller) and "which content" (layout XML). Multiple modules can extend the same page without changing the controller, via their own layout/handle.xml files. That is not something classic MVC provides for.
5 What is the difference between Block and Template?
Block: PHP class, rendering container in the layout system, manages caching and child blocks. Template: phtml file, renders the actual HTML. Always a pair: block provides logic/ViewModel, template renders HTML. Modern recommendation: logic in ViewModels, not in block classes.
6 How does Magento process an HTTP request?
1. 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?
Area: technical dividing line (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.
8 Is Magento 2 closer to MVC or MVVM?
With the ViewModel pattern (especially in Hyva) closer to MVVM: block = view, ViewModel = ViewModel, repository = model. But the layout XML orchestration layer makes it a Magento-specific hybrid, sometimes called HMVC (Hierarchical MVC).
9 Why does the view fetch its own data?
Maximum decoupling: the mini cart block in the header appears on every page, the controller of a product page knows nothing about it and doesn't need to. Every block is responsible for its own data. Separation of concerns at the block level instead of the controller level.
10 How does the Hyva Theme change Magento's architecture?
Hyva only replaces the view layer: no Knockout.js, no jQuery, no UI Components. Instead Alpine.js plus Tailwind CSS. ViewModels become the standard method. The controller and layout XML layer remain unchanged. Result: a noticeably simpler, faster, and more testable frontend layer.