from indexer.xml to a complete Mview cycle
Anyone who needs their own data to be searchable or aggregated at speed cannot avoid building a custom indexer. Instead of running expensive calculations on every frontend request, a custom indexer moves the work into a controlled, repeatable process that runs exactly when the underlying data changes.
Table of Contents
- 1. When a custom indexer actually pays off
- 2. IndexerInterface and ActionInterface at a glance
- 3. Declaring the indexer in indexer.xml
- 4. The action class: execute, executeFull, executeList, executeRow
- 5. Designing a clean custom index table
- 6. Hooking up Mview: understanding changelog tables
- 7. Registering via di.xml and dependency injection
- 8. CLI integration, debugging and testing
- 9. Custom indexer compared to alternatives
- 10. Summary
- 11. FAQ
1. When a custom indexer actually pays off
A custom indexer in Magento 2 makes sense whenever a calculation is needed repeatedly but rarely needs to be recomputed. A classic example: aggregated stock across several external warehouses, a computed popularity score for products, or a denormalized pricing table for an individual B2B pricing model. Without an indexer, this calculation would rerun on every page view, needlessly extending response times and putting the database under load.
Magento itself demonstrates the pattern a custom indexer should follow through its built in indexers such as catalog_product_price or catalogsearch_fulltext: raw data lives in normalized tables, an indexer compresses it into a fast readable target table, and that target table is what the frontend actually reads instead of the raw data. The indexer itself runs either through cron in the background or synchronously on save, depending on the chosen mode.
Before building a custom indexer, it is worth asking whether a simpler mechanism would suffice. Small, rarely changing data can be covered just as well by a cache tag in the full page cache. A custom indexer only pays off once the calculation itself is expensive, once many records are affected, or once incremental reindex through single change events, meaning Mview, brings a real performance benefit over full recalculation.
2. IndexerInterface and ActionInterface at a glance
A custom indexer in Magento 2 consists of several cooperating parts. The Magento\Framework\Indexer\IndexerInterface is the outer facade addressed by bin/magento indexer:reindex and the admin grid. The actual logic, however, does not sit in this interface but in a separate action class that implements Magento\Framework\Indexer\ActionInterface. This separation allows the same indexer logic to be reused for both full and partial reindex runs.
On top of that, incremental reindex brings in Magento\Framework\Mview\ActionInterface, which processes only the affected entity IDs via execute(array $ids). A custom indexer must therefore serve at least two interfaces: one for the complete run over all records and one for a targeted run over an ID list. Magento automatically generates the required indexer tables and views once the declaration in indexer.xml is correct.
It is important to understand that a custom indexer in Magento is not a single PHP object but a combination of declaration, data storage and execution logic. Anyone who only writes the action class but forgets the declaration ends up with an indexer that is invisible to both the CLI and the admin grid. The following sections build these parts in the order they are actually needed when developing a custom indexer.
3. Declaring the indexer in indexer.xml
Every custom indexer starts with a declaration in etc/indexer.xml. There, a unique indexer code is assigned, the action class is referenced, a view for the Mview mechanism is linked, and a readable title for the admin grid is set. The indexer code is the central identifier under which the indexer is later addressed via the CLI, for example bin/magento indexer:reindex vendor_pricematrix.
The indexer node also supports a class attribute for the concrete indexer implementation along with optional child nodes for title, description and fieldset. For coordination with other indexers, dependencies can be defined via <dependencies>, so a custom indexer can, for example, only run after catalog_product_price has finished, if it builds on that indexer's results.
<?xml version="1.0"?>
<!-- app/code/Vendor/PriceMatrix/etc/indexer.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Indexer/etc/indexer.xsd">
<indexer id="vendor_pricematrix"
view_id="vendor_pricematrix"
class="Vendor\PriceMatrix\Model\Indexer\PriceMatrixIndexer">
<title translate="true">B2B Price Matrix</title>
<description translate="true">Aggregates customer group prices into a flat table</description>
<!-- Runs only after the core price indexer has finished -->
<dependencies>
<indexer id="catalog_product_price"/>
</dependencies>
</indexer>
</config>
In parallel, etc/mview.xml defines the view with the same view_id, including the tables whose changes should be watched and the class table observer that derives change IDs from them. Without this view, a custom indexer stays limited to full reindex runs and cannot be updated incrementally, even if the indexer mode in the admin grid is set to update on save.
4. The action class: execute, executeFull, executeList, executeRow
The action class of a custom indexer implements IndexerActionInterface with four methods: executeFull() for a complete rebuild, executeList(array $ids) for a list of affected IDs, executeRow($id) for a single record, and execute($ids) as a generic variant that internally usually delegates to executeList. This split lets Magento call the right method depending on the trigger: a manual indexer:reindex calls executeFull, a save operation in the admin calls executeRow.
In practice, it is a good idea to move the actual data processing into a separate, injectable class and keep the action class itself thin. That way, the same processing logic can be reused by the action class as well as by a custom CLI command or a message queue consumer. This reduces duplication and makes the custom indexer easier to test, because the pure calculation logic can be unit tested without any framework dependencies.
<?php
declare(strict_types=1);
namespace Vendor\PriceMatrix\Model\Indexer;
use Magento\Framework\Indexer\ActionInterface;
use Magento\Framework\Mview\ActionInterface as MviewActionInterface;
use Vendor\PriceMatrix\Model\ResourceModel\PriceMatrix\PriceCalculator;
/**
* Custom indexer action building the flattened B2B price matrix table.
*/
class PriceMatrixIndexer implements ActionInterface, MviewActionInterface
{
/**
* @param PriceCalculator $priceCalculator Handles the actual price aggregation.
*/
public function __construct(
private readonly PriceCalculator $priceCalculator
) {
}
/**
* Full reindex, triggered by bin/magento indexer:reindex.
*
* @return void
*/
public function executeFull(): void
{
$this->priceCalculator->rebuildAll();
}
/**
* Partial reindex for a list of product IDs (Mview or CLI --id).
*
* @param int[] $ids
* @return void
*/
public function executeList(array $ids): void
{
$this->priceCalculator->rebuildForIds($ids);
}
/**
* Partial reindex for a single product ID, used when saving in Admin.
*
* @param int $id
* @return void
*/
public function executeRow($id): void
{
$this->priceCalculator->rebuildForIds([(int) $id]);
}
/**
* Generic entry point used by the Mview changelog processor.
*
* @param int[] $ids
* @return void
*/
public function execute($ids): void
{
$this->executeList((array) $ids);
}
}
5. Designing a clean custom index table
The target table of a custom indexer should always be flat and read friendly, with no joins at runtime. In practice this means every value the frontend needs is already denormalized into a single row, indexed on the columns actually used for filtering. For the B2B price matrix, that would be a table with product ID and customer group as a composite primary key, plus an index matching the most common query direction.
A common mistake when building a custom indexer is truncating the target table directly and refilling it during a full reindex. With large datasets this creates a window where the table is empty or incomplete and the frontend serves wrong results. The established approach is a replace table: data is written into a temporary table, and only at the end is it swapped atomically against the production table via RENAME TABLE, exactly how Magento's own indexers handle it internally.
| Mode | Trigger | Method | Typical use |
|---|---|---|---|
| Update on Save | Admin save | executeRow |
Small dataset, immediate consistency required |
| Update by Schedule | Cron via Mview | executeList |
Large catalog, batch processing desired |
| Full Reindex | indexer:reindex |
executeFull |
Initial population, deploy, data repair |
| Save deferred | After bulk import | executeList |
Import pipelines with batched IDs |
6. Hooking up Mview: understanding changelog tables
Mview stands for materialized view and is the mechanism Magento uses to log changes to source tables in changelog tables so they can be processed incrementally later. As soon as a custom indexer is registered in mview.xml, Magento automatically creates a table such as vendor_pricematrix_cl and attaches triggers to the watched source tables. Every insert, update or delete on these tables writes the affected entity ID into the changelog table.
The actual reindex run then reads this changelog table, collects the IDs since the last recorded version, and calls executeList with exactly those IDs. This is the core of partial reindex and the reason a custom indexer with a correctly configured Mview reacts significantly faster than a full rebuild. It is important to cover all relevant source tables in the view, otherwise changes to them simply go unnoticed.
<?xml version="1.0"?>
<!-- app/code/Vendor/PriceMatrix/etc/mview.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Mview/etc/mview.xsd">
<view id="vendor_pricematrix" class="Vendor\PriceMatrix\Model\Indexer\PriceMatrixIndexer" group="indexer">
<subscriptions>
<table name="catalog_product_entity" entity_column="entity_id"/>
<table name="customer_group" entity_column="customer_group_id"/>
</subscriptions>
</view>
</config>
7. Registering via di.xml and dependency injection
Besides indexer.xml and mview.xml, a custom indexer usually needs entries in etc/di.xml to register the target table as a resource and optionally hook the indexer into existing observers, for example to trigger a targeted invalidation on every product save. The framework already ships a standard observer, Magento\Indexer\Observer\AbstractInvalidateIndexerObserver, that can be reused with only a few lines.
Another important building block is registering the indexer as a consumer for message queue based asynchronous processing, if the calculation is expensive enough that it should no longer run synchronously in the cron process. For most custom indexers, however, the standard mechanism of cron and Mview is entirely sufficient; an additional message queue layer only pays off with very high data volume or external API dependencies in the calculation.
8. CLI integration, debugging and testing
Once declaration and action class are in place, the custom indexer automatically shows up in bin/magento indexer:info and can be triggered individually with bin/magento indexer:reindex vendor_pricematrix. The mode can be switched with indexer:set-mode schedule vendor_pricematrix or realtime. For troubleshooting, indexer:status is the first stop to check whether an indexer is marked invalid and needs a reindex.
When debugging a custom indexer run, it helps to inspect the changelog table directly to verify triggers are actually creating entries. If an entry is missing after an expected change, a forgotten <table> entry in mview.xml is usually the cause. Unit tests for the action class should test the calculation logic in isolation from the database, while integration tests with real fixtures verify that an executeFull run actually produces the correct rows in the target table.
# Show all registered indexers, including the custom one
bin/magento indexer:info
# Full reindex for a single custom indexer
bin/magento indexer:reindex vendor_pricematrix
# Switch to scheduled (cron-driven) mode
bin/magento indexer:set-mode schedule vendor_pricematrix
# Check current status: valid, invalid, working
bin/magento indexer:status vendor_pricematrix
# Inspect the changelog table directly for debugging
bin/mysql -e "SELECT * FROM vendor_pricematrix_cl ORDER BY version_id DESC LIMIT 20;"
9. Custom indexer compared to alternatives
Not every recurring calculation needs a custom indexer right away. Depending on data volume, freshness requirements and complexity, there are alternatives that require less implementation effort but also offer less control over the invalidation timing.
| Approach | Freshness | Implementation effort | When it makes sense |
|---|---|---|---|
| Custom indexer | Controlled, incremental | High | Large datasets, expensive calculation, clear invalidation logic |
| Cron job with own table | Fixed intervals | Medium | A few minutes of delay acceptable, no Mview triggers needed |
| Cache tag in FPC | Recomputed on every request | Low | Small, cheap calculations without database access |
| Observer + save event | Immediate on change | Low to medium | Individual entities, no batch processing needed |
The decisive advantage of a custom indexer over a plain cron job is the hookup to Mview: changes are captured event based, instead of checking on a fixed interval whether anything has changed at all. That significantly reduces unnecessary reindex runs, especially in catalogs where only a small fraction of products changes per day. A custom indexer is almost always the right choice once data volume or calculation cost rule out simply recomputing on every request.
Mironsoft
Magento 2 architecture, indexers and performance engineering
Does your data model need a custom indexer?
We design, implement and test tailored indexers for Magento 2, including Mview hookup, table design and monitoring, so expensive calculations never happen in the frontend again.
Indexer design
Planning table structure, invalidation logic and dependencies carefully
Implementation
Delivering production ready indexer.xml, action classes and Mview configuration
Monitoring
Keeping reindex runtimes, error rates and invalidations under permanent watch
10. Summary
A custom indexer in Magento 2 consists of a clear declaration in indexer.xml, an action class with the four methods executeFull, executeList, executeRow and execute, a flat target table with an atomic swap via RENAME TABLE, and an Mview registration for incremental reindex. Anyone who cleanly separates these building blocks gets an indexer that integrates seamlessly with the existing CLI, admin grid and cron operations.
The biggest mistake when building a custom indexer is ignoring Mview and relying exclusively on full reindex runs instead. That works for small catalogs, but does not scale once the data volume grows. A clean separation between calculation logic and framework integration also makes the custom indexer testable and maintainable long term, even as the underlying business rules change.
Building custom indexers in Magento 2, the essentials at a glance
Declaration
indexer.xml with a unique code, action class and optional dependencies on other indexers.
Action class
executeFull, executeList and executeRow cleanly separate full and partial reindex.
Mview
Changelog tables capture changes event based and enable true partial reindex.
Table design
Flat target table, atomic swap via RENAME TABLE, no joins at runtime.