Magento 2 indexers maintain denormalised data structures – flat tables, price indexes, category-product mappings – that make frontend queries fast. The built-in indexers cover most needs, but complex custom features sometimes need their own. I show how to build a custom indexer from scratch: mview.xml for change tracking, the indexer class with full and partial reindex, and triggering via cron.
How Magento indexers work
Indexers maintain derived data. When a product changes, Magento does not immediately update every denormalised table – it marks the product as “invalidated”. The next reindex (cron or manual) rebuilds that data. This is cheaper than updating every derived table on every save.
Two modes: Update on Save (reindexes immediately on entity save) and Update by Schedule (marks changed rows via mview, reindexes in batches on cron).
Module structure
Vendor/CustomIndex/
etc/
indexer.xml - declares the indexer
mview.xml - change tracking subscriptions
module.xml
Model/
Indexer/
ProductMargin.php - indexer class
ProductMarginHandler.php - row-level handler for partial reindex
Setup/
Patch/Schema/
CreateMarginIndexTable.php
Create the flat index table
<?php
declare(strict_types=1);
namespace Vendor\CustomIndex\Setup\Patch\Schema;
use Magento\Framework\DB\Ddl\Table;
class CreateMarginIndexTable implements \Magento\Framework\Setup\Patch\SchemaPatchInterface
{
public function __construct(
private \Magento\Framework\Setup\SchemaSetupInterface $setup
) {}
public function apply(): static
{
$this->setup->startSetup();
$conn = $this->setup->getConnection();
// Our custom index table: product_id -> margin data
$tableName = $this->setup->getTable('vendor_product_margin_index');
if (!$conn->isTableExists($tableName)) {
$table = $conn->newTable($tableName)
->addColumn('product_id', Table::TYPE_INTEGER, null,
['unsigned' => true, 'nullable' => false, 'primary' => true])
->addColumn('cost', Table::TYPE_DECIMAL, '12,4',
['nullable' => true, 'default' => null])
->addColumn('price', Table::TYPE_DECIMAL, '12,4',
['nullable' => false, 'default' => 0])
->addColumn('margin', Table::TYPE_DECIMAL, '12,4',
['nullable' => true, 'default' => null])
->addColumn('margin_percent', Table::TYPE_DECIMAL, '6,2',
['nullable' => true, 'default' => null])
->addColumn('updated_at', Table::TYPE_TIMESTAMP, null,
['nullable' => false, 'default' => Table::TIMESTAMP_INIT_UPDATE])
->setComment('Product margin index');
$conn->createTable($table);
}
$this->setup->endSetup();
return $this;
}
public static function getDependencies(): array { return []; }
public function getAliases(): array { return []; }
}
indexer.xml – declare the indexer
<?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="vendor_product_margin"
view_id="vendor_product_margin"
class="Vendor\CustomIndex\Model\Indexer\ProductMargin"
primary="catalog_product">
<title translate="true">Product Margin Index</title>
<description translate="true">Maintains product margin and cost data</description>
</indexer>
</config>
mview.xml – subscribe to data changes
<?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="vendor_product_margin"
class="Vendor\CustomIndex\Model\Indexer\ProductMargin"
group="indexer">
<subscriptions>
<!-- Watch the main product table -->
<table name="catalog_product_entity" entity_column="entity_id"/>
<!-- Watch EAV decimal values (price, cost are decimal attributes) -->
<table name="catalog_product_entity_decimal" entity_column="entity_id"/>
</subscriptions>
</view>
</config>
Indexer class – full and partial reindex
<?php
declare(strict_types=1);
namespace Vendor\CustomIndex\Model\Indexer;
use Magento\Framework\Indexer\ActionInterface;
use Magento\Framework\Mview\ActionInterface as MviewActionInterface;
class ProductMargin implements ActionInterface, MviewActionInterface
{
private const TABLE = 'vendor_product_margin_index';
public function __construct(
private \Magento\Framework\App\ResourceConnection $resourceConnection,
private \Psr\Log\LoggerInterface $logger
) {}
// Called by bin/magento indexer:reindex vendor_product_margin
public function executeFull(): void
{
$this->logger->info('ProductMargin: full reindex started');
$connection = $this->resourceConnection->getConnection();
// Truncate and rebuild from scratch
$connection->truncateTable($this->resourceConnection->getTableName(self::TABLE));
$this->rebuildAll($connection);
$this->logger->info('ProductMargin: full reindex complete');
}
// Called for a list of specific product IDs
public function executeList(array $ids): void
{
if (empty($ids)) return;
$this->reindexIds($ids);
}
// Called for a single product ID
public function executeRow($id): void
{
$this->reindexIds([(int)$id]);
}
// Called by mview (scheduled mode) with changed IDs since last run
public function execute($ids): void
{
if (empty($ids)) return;
$this->reindexIds(array_map('intval', $ids));
}
private function rebuildAll(\Magento\Framework\DB\Adapter\AdapterInterface $connection): void
{
// Calculate margin for ALL products in one SQL query
$select = $connection->select()
->from(
['p' => $this->resourceConnection->getTableName('catalog_product_entity')],
['product_id' => 'p.entity_id']
)
->joinLeft(
['price_attr' => $this->resourceConnection->getTableName('catalog_product_entity_decimal')],
"price_attr.entity_id = p.entity_id AND price_attr.attribute_id = {$this->getPriceAttributeId()} AND price_attr.store_id = 0",
['price' => 'price_attr.value']
)
->joinLeft(
['cost_attr' => $this->resourceConnection->getTableName('catalog_product_entity_decimal')],
"cost_attr.entity_id = p.entity_id AND cost_attr.attribute_id = {$this->getCostAttributeId()} AND cost_attr.store_id = 0",
['cost' => 'cost_attr.value']
);
$rows = $connection->fetchAll($select);
$insertData = array_map(function($row) {
$price = (float)($row['price'] ?? 0);
$cost = $row['cost'] !== null ? (float)$row['cost'] : null;
$margin = ($cost !== null && $price > 0) ? $price - $cost : null;
$marginPct = ($margin !== null && $cost > 0) ? ($margin / $cost) * 100 : null;
return [
'product_id' => $row['product_id'],
'cost' => $cost,
'price' => $price,
'margin' => $margin,
'margin_percent' => $marginPct,
];
}, $rows);
// Bulk insert
$tableName = $this->resourceConnection->getTableName(self::TABLE);
foreach (array_chunk($insertData, 1000) as $chunk) {
$connection->insertMultiple($tableName, $chunk);
}
}
private function reindexIds(array $ids): void
{
$connection = $this->resourceConnection->getConnection();
$tableName = $this->resourceConnection->getTableName(self::TABLE);
// Delete existing rows for these IDs
$connection->delete($tableName, ['product_id IN (?)' => $ids]);
// Rebuild for these IDs only
// (same query as rebuildAll but filtered by IDs)
$select = $this->buildSelectForIds($connection, $ids);
$rows = $connection->fetchAll($select);
foreach ($rows as $row) {
$price = (float)($row['price'] ?? 0);
$cost = $row['cost'] !== null ? (float)$row['cost'] : null;
$margin = ($cost !== null && $price > 0) ? $price - $cost : null;
$marginPct = ($margin !== null && $cost > 0) ? ($margin / $cost) * 100 : null;
$connection->insert($tableName, [
'product_id' => $row['product_id'],
'cost' => $cost,
'price' => $price,
'margin' => $margin,
'margin_percent' => $marginPct,
]);
}
}
private function getPriceAttributeId(): int { return 75; } // standard price attribute
private function getCostAttributeId(): int { return 80; } // standard cost attribute
}
# Run the indexer bin/magento indexer:reindex vendor_product_margin # Check status bin/magento indexer:status vendor_product_margin # Switch to scheduled mode (mview) bin/magento indexer:set-mode schedule vendor_product_margin # After saving a product, check if it's in the changelog SELECT * FROM vendor_product_margin_cl ORDER BY version_id DESC LIMIT 5;
Summary
Custom indexers are the right tool when you need denormalised data that Magento does not maintain out of the box – margin calculations, custom scores, aggregated statistics. The mview mechanism ensures that only changed rows are reindexed in scheduled mode, keeping the performance impact minimal. The pattern – declare in indexer.xml, track changes in mview.xml, implement ActionInterface and MviewActionInterface – is consistent across all Magento indexers once you write it once.
