Building Custom Indexers in Magento 2: IndexerInterface, Mview and indexer.xml
AI generated
M2
di.xml
Magento 2 · Indexer · Mview · Architecture
Building Custom Indexers in Magento 2
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.

19 min read indexer.xml · ActionInterface · Mview · CLI Magento 2.4.x

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.

11. FAQ: Building Custom Indexers in Magento 2

1Custom indexer or cron job?
With large data volume and event based invalidation, a custom indexer with Mview clearly beats fixed cron intervals.
2Which interfaces are mandatory?
ActionInterface for executeFull, executeList, executeRow. For incremental reindex, additionally the Mview ActionInterface.
3Indexer missing from indexer:info?
Check the declaration in indexer.xml, then run cache:flush and setup:upgrade.
4executeList vs. executeRow?
executeRow processes a single ID, executeList a whole list for efficient batch processing.
5How does Mview work?
Database triggers write changed IDs into a changelog table, which the reindex run then processes.
6Truncate target table every time?
No, use a temporary table and swap via atomic RENAME TABLE to avoid downtime.
7Dependency on another indexer?
Yes, via the dependencies node in indexer.xml, guaranteeing the correct execution order.
8How to test it?
Calculation logic isolated via unit tests, integration with real fixtures for executeFull and executeList.
9Changelog table stays empty?
Usually a table is missing from the subscriptions block in mview.xml, or the mode is still update on save.
10Worthwhile for small shops too?
Rarely, with a few hundred products a simple cron job or a cache tag in the full page cache is usually enough.