Writing an Indexer

A custom indexer is one action class plus two XML files: indexer.xml declares it and mview.xml lists the tables that trigger it.

Syntaxclass Points implements Magento\Framework\Indexer\ActionInterface, Magento\Framework\Mview\ActionInterface

Suppose you compute loyalty points per product from several tables. Doing the calculation per request is too slow, so you write an indexer: a class that fills a table of your own, plus XML that tells Magento when to run it.

The action class

Implement two interfaces. Magento\Framework\Indexer\ActionInterface declares executeFull(), which rebuilds the whole index, executeList(array $ids), which rebuilds the listed entities, and executeRow($id), which rebuilds one. Magento\Framework\Mview\ActionInterface adds execute($ids), which is what mview calls with the ids it read from the changelog. In core, execute() delegates to the same code as executeList().

The two XML files

etc/indexer.xml declares the indexer id, a view_id and the class. etc/mview.xml declares a view with that same id and lists the tables to subscribe to, each with the column that holds the entity id. Those subscriptions become the MySQL triggers.

Making it run

Flush the config cache with bin/magento cache:flush, or the indexer does not appear in indexer:info at all. Run bin/magento setup:upgrade to create your index table from db_schema.xml, and bin/magento setup:di:compile if you are in production mode. Then bin/magento indexer:reindex vendor_rewards_points for the first full build, and bin/magento indexer:set-mode schedule vendor_rewards_points to create the triggers.

Example

Example · php
<?php
// app/code/Vendor/Rewards/Model/Indexer/Points.php

declare(strict_types=1);

namespace Vendor\Rewards\Model\Indexer;

use Magento\Catalog\Model\Product;
use Magento\Framework\Indexer\ActionInterface as IndexerActionInterface;
use Magento\Framework\Indexer\CacheContext;
use Magento\Framework\Mview\ActionInterface as MviewActionInterface;
use Vendor\Rewards\Model\Indexer\Points\Builder;

class Points implements IndexerActionInterface, MviewActionInterface
{
    public const INDEXER_ID = 'vendor_rewards_points';

    public function __construct(
        private readonly Builder $builder,
        private readonly CacheContext $cacheContext
    ) {
    }

    public function executeFull(): void
    {
        $this->builder->rebuildAll();
        $this->cacheContext->registerTags([Product::CACHE_TAG]);
    }

    public function executeList(array $ids): void
    {
        $ids = array_map('intval', $ids);
        $this->builder->rebuild($ids);
        $this->cacheContext->registerEntities(Product::CACHE_TAG, $ids);
    }

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

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

// bin/magento indexer:reindex vendor_rewards_points
// Product Reward Points index has been rebuilt successfully in 00:00:09

When to use it

  • Precomputing loyalty points per product so the product page does one indexed read.
  • Flattening a vendor-to-product mapping behind a marketplace listing page.
  • Replacing a report query that times out with a table rebuilt on a schedule.
  • Keeping a denormalised table for a third-party search service in step with the catalog.

More examples

etc/indexer.xml declares the indexer

The id is what the CLI takes as an argument. The view_id links this indexer to a view in mview.xml and names the changelog table, which will be vendor_rewards_points_cl. Keep the two the same unless you have a reason not to; core diverges only for the grid indexers, where customer_grid points at a view called customer_dummy and design_config_grid at one called design_config_dummy. The title is what indexer:status prints and what the merchant reads in Index Management, so write it for them. After adding this file, run bin/magento cache:flush.

Example · xml
<?xml version="1.0" encoding="UTF-8"?>
<!-- app/code/Vendor/Rewards/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_rewards_points" view_id="vendor_rewards_points"
             class="Vendor\Rewards\Model\Indexer\Points">
        <title translate="true">Product Reward Points</title>
        <description translate="true">Reward points earned per product</description>
    </indexer>
</config>

etc/mview.xml decides what triggers a partial rebuild

Each table element becomes three MySQL triggers on that table, writing the value of entity_column into the changelog. entity_column is the column that holds your index's entity id, which is why catalog_product_website uses product_id rather than entity_id. Subscribe only to tables whose changes really affect your index: every extra subscription adds triggers to a hot table and more rows for cron to process. The triggers are created when you switch the indexer to schedule mode, so run bin/magento indexer:set-mode schedule vendor_rewards_points afterwards.

Example · xml
<?xml version="1.0" encoding="UTF-8"?>
<!-- app/code/Vendor/Rewards/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_rewards_points" class="Vendor\Rewards\Model\Indexer\Points"
          group="indexer">
        <subscriptions>
            <table name="catalog_product_entity" entity_column="entity_id"/>
            <table name="catalog_product_entity_decimal" entity_column="entity_id"/>
            <table name="catalog_product_website" entity_column="product_id"/>
        </subscriptions>
    </view>
</config>

The promoted constructor and the longhand it replaces

The two forms are the same thing: promotion declares the property, types it and assigns the argument to it. Magento 2.4.9 core is older than promotion and almost always writes the long form with a docblock, so you will read the long form constantly and may prefer the short one in your own modules. Either way the object manager reads the constructor signature to decide what to inject, and a parameter it cannot resolve from the type is what di.xml arguments are for.

Example · php
<?php
// app/code/Vendor/Rewards/Model/Indexer/Points.php (constructor only)

declare(strict_types=1);

// Shorthand: constructor property promotion, PHP 8.0 and later.
class Points
{
    public function __construct(
        private readonly Builder $builder,
        private readonly CacheContext $cacheContext
    ) {
    }
}

// Longhand: exactly the same class. This is what promotion desugars to.
class Points
{
    private readonly Builder $builder;

    private readonly CacheContext $cacheContext;

    public function __construct(Builder $builder, CacheContext $cacheContext)
    {
        $this->builder = $builder;
        $this->cacheContext = $cacheContext;
    }
}

Discussion

  • Be the first to comment on this lesson.