Build a Custom Indexer in Magento 2: Step-by-Step

Build a Custom Indexer in Magento 2 | Step-by-Step Guide

Build a Custom Indexer in Magento 2: Step-by-Step

Most Magento 2 indexer tutorials stop at bin/magento indexer:reindex. That's fine for day-to-day operations. But it tells you nothing about building an indexer from scratch — and it especially leaves out incremental updates, the kind that don't hammer your database on every product save.

This guide covers custom indexer Magento 2 development end-to-end. You'll work through module setup, IndexerInterface implementation, and full Mview integration for change tracking. By the end, you'll have a production-ready indexer that behaves exactly like Magento's native ones.

---

What You'll Need

- Magento 2.4.x installation (2.4.6+ recommended) - PHP 8.2+ - Basic familiarity with Magento module structure and dependency injection - MySQL access for verifying changelog tables - CLI access to the Magento root

---

What Is the Magento 2 Indexing System — and When to Build a Custom Indexer

Magento's indexing system solves a performance problem. Computing product prices, category paths, or search data on the fly during a storefront request is expensive. Instead, Magento pre-computes that data and stores it in flat index tables — simplified, denormalized copies of your data optimized for fast reads. When a shopper hits a category page, Magento loads pre-computed data instantly.

Key terms: A flat table is a pre-computed, denormalized snapshot of your data. Denormalized means related data is merged into one table instead of joined across many. Reads are fast, but the data needs periodic updates — which is exactly what indexers do.

The framework provides two reindex modes:

- Full reindex — Rebuilds the entire index table from scratch. Useful after large data imports, but slow on big catalogs. - Partial (incremental) reindex — Reprocesses only entities that changed since the last index run. This is where Mview (Magento's change-tracking layer) comes in.

When do you actually need a custom indexer? Here are the scenarios that justify building one:

  • You're building a custom pricing or discount engine that needs denormalized data for fast reads.
  • A third-party ERP pushes product attributes that need pre-aggregating into a flat table for a custom PLP filter.
  • You're maintaining a bespoke search index in a non-Elasticsearch datastore.
  • Custom bundle configurations need relationship data flattened for API responses.
  • If you're writing a cron job that runs SELECT queries and dumps data into a flat table on a schedule — stop. You almost certainly want a proper indexer instead.

    ---

    Setting Up Your Module and Declaring the Indexer in indexer.xml

    The example module for this tutorial is Unomage_CustomIndex. It indexes a fictional "vendor score" per product into a flat table.

    Module structure

    The module follows standard Magento conventions. Here's a quick overview before the full tree:

    - etc/indexer.xml — registers your indexer with Magento - etc/mview.xml — configures change tracking - Model/Indexer/VendorScore.php — contains your reindex logic

    app/code/Unomage/CustomIndex/
    

    ├── etc/ │ ├── module.xml │ ├── indexer.xml │ └── mview.xml ├── Model/ │ └── Indexer/ │ └── VendorScore.php ├── registration.php └── composer.json

    registration.php

    <?php
    

    use Magento\Framework\Component\ComponentRegistrar;

    ComponentRegistrar::register( ComponentRegistrar::MODULE, 'Unomage_CustomIndex', __DIR__ );

    etc/module.xml

    <?xml version="1.0"?>
    

    <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> <module name="Unomage_CustomIndex"> <sequence> <module name="Magento_Catalog"/> <module name="Magento_Indexer"/> </sequence> </module> </config>

    etc/indexer.xml

    This file registers your indexer with Magento's indexer system:

    <?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="unomage_vendor_score" view_id="unomage_vendor_score" class="Unomage\CustomIndex\Model\Indexer\VendorScore" primary="catalog_product"> <title translate="true">Vendor Score Index</title> <description translate="true">Indexes vendor score data per product for fast reads</description> </indexer> </config>

    Key attributes to understand:

    - id — Unique identifier used in CLI commands and programmatic calls. - view_id — Links this indexer to an Mview view (defined in mview.xml). - class — Your IndexerInterface implementation. - primary — The primary table being watched for changes.

    Critical: The view_id must exactly match the id attribute in your mview.xml. A mismatch causes silent failures — the indexer runs, but Mview change tracking never activates.

    ---

    Implementing IndexerInterface and Writing Your Reindex Logic

    Your indexer class must implement two interfaces. Magento\Framework\Indexer\ActionInterface handles scheduled and manual reindex calls. MviewActionInterface handles incremental updates from the changelog.

    These interfaces require three core methods:

    - executeFull() — Full reindex of all entities. - executeList(array $ids) — Partial reindex for a specific set of IDs. - executeRow($id) — Reindex a single entity.

    Model/Indexer/VendorScore.php

    <?php
    

    declare(strict_types=1);

    namespace Unomage\CustomIndex\Model\Indexer;

    use Magento\Framework\Indexer\ActionInterface; use Magento\Framework\Mview\ActionInterface as MviewActionInterface; use Magento\Framework\App\ResourceConnection; use Psr\Log\LoggerInterface;

    class VendorScore implements ActionInterface, MviewActionInterface { private const INDEX_TABLE = 'unomage_vendor_score_index';

    public function __construct( private readonly ResourceConnection $resourceConnection, private readonly LoggerInterface $logger ) {}

    public function executeFull(): void { $this->logger->info('VendorScore: Starting full reindex'); $connection = $this->resourceConnection->getConnection(); $indexTable = $this->resourceConnection->getTableName(self::INDEX_TABLE);

    $connection->truncateTable($indexTable); $this->reindexAll($connection, $indexTable);

    $this->logger->info('VendorScore: Full reindex complete'); }

    public function executeList(array $ids): void { if (empty($ids)) { return; } $this->reindexByIds($ids); }

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

    /* Called by Mview on incremental reindex / public function execute($ids): void { $this->executeList((array)$ids); }

    private function reindexAll($connection, string $indexTable): void { $select = $connection->select() ->from( ['p' => $this->resourceConnection->getTableName('catalog_product_entity')], ['entity_id'] ) ->joinLeft( ['v' => $this->resourceConnection->getTableName('vendor_product_data')], 'p.entity_id = v.product_id', ['vendor_score' => 'COALESCE(v.score, 0)'] );

    $rows = $connection->fetchAll($select);

    if (!empty($rows)) { $connection->insertMultiple($indexTable, $rows); } }

    private function reindexByIds(array $ids, $connection = null): void { $connection = $connection ?? $this->resourceConnection->getConnection(); $indexTable = $this->resourceConnection->getTableName(self::INDEX_TABLE);

    $connection->delete($indexTable, ['entity_id IN (?)' => $ids]);

    $select = $connection->select() ->from( ['p' => $this->resourceConnection->getTableName('catalog_product_entity')], ['entity_id'] ) ->joinLeft( ['v' => $this->resourceConnection->getTableName('vendor_product_data')], 'p.entity_id = v.product_id', ['vendor_score' => 'COALESCE(v.score, 0)'] ) ->where('p.entity_id IN (?)', $ids);

    $rows = $connection->fetchAll($select);

    if (!empty($rows)) { $connection->insertMultiple($indexTable, $rows); } } }

    The class implements both ActionInterface and MviewActionInterface. The execute($ids) method is the Mview entry point — it receives IDs collected from the changelog during an incremental reindex.

    Warning: Never use executeFull() for partial updates. It truncates the entire table. Developers often discover this when unrelated products disappear from the index mid-reindex.

    ---

    Handling Partial Reindex with Mview and Change Tracking

    Mview is Magento's change-tracking layer. When an entity changes, Mview records its ID in a changelog table. On the next scheduled index run, only those changed IDs get processed — which is what separates a properly built custom indexer from a brute-force cron job.

    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="unomage_vendor_score" class="Unomage\CustomIndex\Model\Indexer\VendorScore" group="indexer"> <subscriptions> <table name="catalog_product_entity" entity_column="entity_id"/> <table name="vendor_product_data" entity_column="product_id"/> </subscriptions> </view> </config>

    The block tells Mview which tables to watch. When a row changes in either catalog_product_entity or vendor_product_data, the associated ID is written to the changelog table automatically via database triggers.

    What Mview creates under the hood

    After running bin/magento setup:db:schema:upgrade with "Update by Schedule" mode enabled, Magento creates two things:

    - A changelog table: unomage_vendor_score_cl — stores version_id and entity_id for every change. - Database triggers on your subscribed tables that INSERT into the changelog on INSERT, UPDATE, and DELETE.

    Verify this directly:

    mysql -u magento -p magento_db -e "SHOW TABLES LIKE '%vendor_score%';"
    

    mysql -u magento -p magento_db -e "SELECT FROM unomage_vendor_score_cl ORDER BY version_id DESC LIMIT 10;"

    Switching to "Update by Schedule" mode

    bin/magento indexer:set-mode schedule unomage_vendor_score

    Once set, every save to a subscribed table triggers changelog writes. The indexer processes these during cron via the indexer_reindex_all_invalid and indexer_update_all_views jobs.

    Pro tip: Use "Update on Save" mode during development (bin/magento indexer:set-mode realtime unomage_vendor_score) to catch bugs immediately. Switch to schedule mode for staging and production.

    ---

    Testing, Debugging, and Running Your Indexer via CLI

    Installing the module

    bin/magento module:enable Unomage_CustomIndex
    

    bin/magento setup:upgrade bin/magento setup:db:schema:upgrade bin/magento cache:flush

    Running a full reindex

    bin/magento indexer:reindex unomage_vendor_score

    Expected output:

    Vendor Score Index index has been rebuilt successfully in 00:00:02

    Reindex programmatically

    Sometimes you need to trigger a reindex from within PHP — after a bulk import, for example. Always check the indexer mode first:

    <?php
    

    use Magento\Framework\Indexer\IndexerRegistry;

    class MyImportService { public function __construct( private readonly IndexerRegistry $indexerRegistry ) {}

    public function afterImport(array $productIds): void { $indexer = $this->indexerRegistry->get('unomage_vendor_score');

    if ($indexer->isScheduled()) { return; // Mview handles it automatically }

    $indexer->reindexList($productIds); } }

    Calling reindexFull() programmatically after every import on a 500k-product catalog will take down your cron system. Check the mode first, then act accordingly.

    Common debugging steps

    Indexer shows "requires reindex" immediately after running: Add explicit logging and use the -v flag to surface silent exceptions:

    bin/magento indexer:reindex unomage_vendor_score -v

    Changelog table not being created: Verify the view_id in indexer.xml matches the id in mview.xml exactly, then re-run setup:upgrade:

    bin/magento setup:upgrade --dry-run 2>&1 | grep vendor_score

    Mview not firing during saves: Confirm schedule mode is active and that database triggers exist:

    mysql -u magento -p magento_db -e "SHOW TRIGGERS LIKE 'catalog_product_entity%';"

    Look for triggers named like trg_unomage_vendor_score_cl_insert.

    Index table is empty after full reindex: Your SQL query is returning zero rows. Copy the generated SQL from your logger output and run it directly against the database to isolate the problem.

    ---

    Wrapping Up

    Building a custom indexer in Magento 2 involves four pieces working together: the indexer.xml declaration, your ActionInterface and MviewActionInterface implementation, the mview.xml subscription config, and the underlying index table schema.

    Get the view_id matching right. Implement both interfaces completely. Watch changelog tables to confirm Mview tracks changes. Always check the indexer's current mode before triggering programmatic reindexes.

    The native Magento indexers — catalog price, category flat, search — all follow this exact pattern. Once you've built one custom indexer this way, reading through vendor/magento/module-catalog/Model/Indexer/ becomes far less intimidating. The patterns repeat everywhere.

    Quality Score: 63/100



    Jot us a note and we’ll get back to you as quickly as possible.
    Copyright © 2021-2026 Unomage, LLC. All rights reserved.