PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Custom indexer – mview.xml, flat table, full and partial reindex, cron-triggered

by Henryk Tews / Tuesday, 21 January 2025 / Published in Magento 2

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.

About Henryk Tews

What you can read next

Xdebug – configuration, PHPStorm, debugging Magento plugins
Strategy pattern in PHP – and how Magento 2 uses it in pricing

© 2026 Created by

TOP
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Always active
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Manage options
  • Manage services
  • Manage {vendor_count} vendors
  • Read more about these purposes
Zobacz preferencje
  • {title}
  • {title}
  • {title}