← Back to blog

How to Create a Custom Indexer in Magento 2 (with Mview)

Rodrigo Biazzi

Magento uses indexers to turn normalized data into flat tables that are fast to read. Prices, stock, URL rewrites, category products and the search index all work this way. When your own module needs precomputed data, for example a per product score built from several tables, or a denormalized table for an export, a custom indexer gives you the same machinery: a full reindex command, partial updates through database triggers, and status visible in the admin.

This guide builds one for Magento Open Source or Adobe Commerce 2.4.8.

How indexing works

An indexer has two modes, set per indexer:

  • Update on Save (realtime). Code that changes data calls the indexer directly, and the index is updated in the same request.
  • Update by Schedule. Magento creates MySQL triggers on the tables you subscribe to. Each insert, update or delete writes the entity ID into a changelog table. A cron job reads the changelog and calls your indexer with the list of changed IDs.

The schedule mode is what you want on any store with real traffic or regular imports. It moves indexing out of the save request and groups changes together. The trade offs between the two modes are covered in the indexer modes article.

The part responsible for triggers and changelogs is called Mview. A custom indexer usually has both an indexer declaration and an Mview view.

The example

The indexer will maintain acme_product_score, a table with one row per product and a score calculated from price and stock. It subscribes to the product entity table, the price attribute table and the stock item table, so changes to any of them trigger a partial reindex.

Declaring the indexer

etc/indexer.xml:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Indexer/etc/indexer.xsd">
    <indexer id="acme_product_score"
             view_id="acme_product_score"
             class="Acme\ProductScore\Model\Indexer\ProductScore">
        <title translate="true">Acme Product Score</title>
        <description translate="true">Calculates a sort score per product</description>
    </indexer>
</config>

etc/mview.xml:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Mview/etc/mview.xsd">
    <view id="acme_product_score"
          class="Acme\ProductScore\Model\Indexer\ProductScore"
          group="indexer">
        <subscriptions>
            <table name="catalog_product_entity" entity_column="entity_id"/>
            <table name="catalog_product_entity_decimal" entity_column="entity_id"/>
            <table name="cataloginventory_stock_item" entity_column="product_id"/>
        </subscriptions>
    </view>
</config>

The view_id in indexer.xml must match the id in mview.xml. The entity_column is the column whose value goes into the changelog, and it must be the product ID in every subscribed table.

On Adobe Commerce, catalog entity tables use row_id as the link field because of content staging, while entity_id stays on catalog_product_entity. The attribute value tables there have row_id, not entity_id. If the module must work on both editions, check how core mview.xml files handle it for your version before subscribing to attribute tables.

The indexer class

The class implements two interfaces. Magento\Framework\Indexer\ActionInterface is used by the CLI and by realtime mode. Magento\Framework\Mview\ActionInterface is used by the schedule mode cron.

<?php
declare(strict_types=1);

namespace Acme\ProductScore\Model\Indexer;

use Acme\ProductScore\Model\ScoreBuilder;
use Magento\Framework\Indexer\ActionInterface as IndexerActionInterface;
use Magento\Framework\Mview\ActionInterface as MviewActionInterface;

class ProductScore implements IndexerActionInterface, MviewActionInterface
{
    private const BATCH_SIZE = 1000;

    public function __construct(
        private readonly ScoreBuilder $scoreBuilder
    ) {
    }

    public function executeFull(): void
    {
        $this->scoreBuilder->rebuildAll(self::BATCH_SIZE);
    }

    public function executeList(array $ids): void
    {
        $this->reindexIds($ids);
    }

    public function executeRow($id): void
    {
        $this->reindexIds([$id]);
    }

    public function execute($ids): void
    {
        $this->reindexIds($ids);
    }

    private function reindexIds(array $ids): void
    {
        $ids = array_values(array_unique(array_map('intval', $ids)));
        foreach (array_chunk($ids, self::BATCH_SIZE) as $chunk) {
            $this->scoreBuilder->rebuildForProducts($chunk);
        }
    }
}

Keep the indexer class small and put the SQL in a separate service. That makes the logic testable and keeps the entry points easy to read.

Writing the index data

Indexers should work with SQL, not with models and repositories. Loading 100,000 products as objects to compute a number is the slowest possible approach. A partial rebuild deletes the rows for the given IDs and inserts fresh ones from a select:

<?php
declare(strict_types=1);

namespace Acme\ProductScore\Model;

use Magento\Framework\App\ResourceConnection;
use Magento\Framework\DB\Select;
use Magento\Framework\DB\Sql\Expression;

class ScoreBuilder
{
    public function __construct(
        private readonly ResourceConnection $resource
    ) {
    }

    public function rebuildForProducts(array $productIds): void
    {
        if ($productIds === []) {
            return;
        }
        $connection = $this->resource->getConnection();
        $table = $this->resource->getTableName('acme_product_score');

        $connection->beginTransaction();
        try {
            $connection->delete($table, ['product_id IN (?)' => $productIds]);
            $select = $this->buildSelect()->where('e.entity_id IN (?)', $productIds);
            $connection->query($connection->insertFromSelect($select, $table, ['product_id', 'score']));
            $connection->commit();
        } catch (\Throwable $e) {
            $connection->rollBack();
            throw $e;
        }
    }

    public function rebuildAll(int $batchSize): void
    {
        $connection = $this->resource->getConnection();
        $table = $this->resource->getTableName('acme_product_score');
        $replica = $this->resource->getTableName('acme_product_score_replica');
        $entityTable = $this->resource->getTableName('catalog_product_entity');

        $connection->truncateTable($replica);
        $maxId = (int) $connection->fetchOne(
            $connection->select()->from($entityTable, [new Expression('MAX(entity_id)')])
        );

        for ($from = 1; $from <= $maxId; $from += $batchSize) {
            $select = $this->buildSelect()
                ->where('e.entity_id >= ?', $from)
                ->where('e.entity_id < ?', $from + $batchSize);
            $connection->query($connection->insertFromSelect($select, $replica, ['product_id', 'score']));
        }

        $connection->renameTablesBatch([
            ['oldName' => $table, 'newName' => $table . '_outdated'],
            ['oldName' => $replica, 'newName' => $table],
            ['oldName' => $table . '_outdated', 'newName' => $replica],
        ]);
    }

    private function buildSelect(): Select
    {
        $connection = $this->resource->getConnection();
        return $connection->select()
            ->from(['e' => $this->resource->getTableName('catalog_product_entity')], ['product_id' => 'entity_id'])
            ->joinLeft(
                ['si' => $this->resource->getTableName('cataloginventory_stock_item')],
                'si.product_id = e.entity_id AND si.stock_id = 1',
                ['score' => new Expression('IF(si.is_in_stock = 1, 100, 0)')]
            );
    }
}

The score formula here is only a placeholder. The pattern is what matters: the partial rebuild deletes and inserts in one transaction, by batches of IDs, entirely in SQL. Both the partial and the full rebuild share the same select, so the two paths can never calculate the score differently. That sounds obvious, but indexers where the full and partial logic drifted apart are a common source of data that is correct after a full reindex and wrong a day later.

Full reindex without empty tables

If executeFull() truncates the live table and refills it, every query during the rebuild sees missing data. That is why rebuildAll() above never touches the live table until the end. It fills acme_product_score_replica, a table with the same structure declared in db_schema.xml, in batches by ID range. Then renameTablesBatch() swaps the two tables in a single RENAME TABLE statement.

MySQL runs a multi table rename atomically, so readers see either the old data or the new data, never a half built table. After the swap, the old data sits in the replica table until the next full rebuild truncates it. Core indexers use the same idea. Iterating by ID range instead of by offset also keeps each batch query cheap, because it uses the primary key and does not have to skip rows.

Running and managing it

After bin/magento setup:upgrade, the indexer is listed with the others:

bin/magento indexer:info
bin/magento indexer:set-mode schedule acme_product_score
bin/magento indexer:reindex acme_product_score
bin/magento indexer:status acme_product_score

Switching to schedule mode creates the triggers and the changelog table acme_product_score_cl. From then on, the indexer_update_all_views cron job processes the changelog. It needs a working cron, as described in the cron jobs guide.

If an indexer is marked invalid, indexer_reindex_all_invalid runs a full reindex from cron. You can invalidate yours from code when a configuration change affects every row, by loading it through Magento\Framework\Indexer\IndexerRegistry and calling invalidate().

Realtime mode and calling the indexer from code

In Update on Save mode, nothing calls your indexer automatically. Core entities call their own indexers from save handlers. For yours, add a plugin or observer on the relevant save, and reindex only when the indexer is not scheduled:

$indexer = $this->indexerRegistry->get('acme_product_score');
if (!$indexer->isScheduled()) {
    $indexer->reindexRow($productId);
}

This check matters. Calling reindexRow() while in schedule mode does the work twice, once now and once from the changelog.

Things to watch

  • Triggers fire for every change to the subscribed tables, including imports. Subscribe only to tables that really affect your index.
  • Changelog tables grow until the cron processes them. If cron stops, they keep growing. Monitor the version of each view with bin/magento indexer:status.
  • Indexers must be idempotent. The same ID can arrive twice, and a partial reindex can run after a full one.
  • Large catalogs need batches. Never load every product ID into PHP at once for a full rebuild. Iterate by ID range.
  • For slow category pages caused by indexing, the slow category pages checklist is a good place to start.

FAQ

Why does my indexer not update in schedule mode?

Check that cron runs, that the triggers exist on the subscribed tables, and that entity_column is correct. Reset the mode with indexer:set-mode realtime and then schedule again to recreate the triggers.

Do I need mview.xml for a custom indexer?

Only if you want schedule mode. Without it the indexer still works for full reindexes and for realtime calls from your own code.

What is the difference between executeList and execute?

executeList comes from the indexer action interface and is used by code that reindexes specific IDs directly. execute comes from the Mview interface and is called by cron with IDs from the changelog. Both usually call the same internal method.

Can I use repositories inside an indexer?

You can, but it is slow on large catalogs. Indexers should read and write with SQL in batches.

I am available for freelance Magento and Adobe Commerce work. If indexing is slowing down your store or imports, get in touch.

Related articles