17.07.2026・TechStuff
17.07.2026・TechStuff

Shopware 6 Hidden Gems #2: Bulk imports done right — taming the indexers

Fabian Blechschmidt

Every Shopware developer has been here: you push 50,000 products through the Sync API and the import crawls. The writes themselves are fast. What kills you is everything that happens after each write — inheritance updates, cheapest price calculation, search keyword generation, category denormalization. Shopware calls all of this „indexing“, and by default it runs synchronously, per request, whether you need it right now or not.

The funny thing: Shopware has a complete toolbox to control exactly this. Headers, context states, command options — all wired up, all used internally. The indexing-behavior header gets a passing mention in the docs. The rest? Nothing. Time to fix that.

Two headers on the Sync API

POST /api/_action/sync reads two HTTP headers before it does anything else (Framework/Api/Controller/SyncController.php:48-53):

curl -X POST https://shop.example/api/_action/sync \
  --header 'indexing-behavior: use-queue-indexing' \
  --header 'indexing-skip: product.search-keyword,product.cheapest-price' \
  --data @products.json

indexing-behavior accepts two values (they are constants in EntityIndexerRegistry):

ValueEffect
use-queue-indexingIndexing is pushed to the message queue instead of running synchronously. Your API call returns as soon as the data is written.
disable-indexingIndexing is skipped entirely. You rebuild it later yourself.

indexing-skip is even more granular: a comma-separated list of indexer names to leave out — while everything else still runs normally.

The skip list goes deeper than you think

Here is the part I did not expect. You can obviously skip whole indexers — every indexer has a name like product.indexer or category.indexer (its getName()). But the skip list also works on sub-updaters inside an indexer. The ProductIndexer alone checks eleven of them (ProductIndexer.php:34-44):

product.inheritance
product.stock
product.variant-listing
product.child-count
product.many-to-many-id-field
product.category-denormalizer
product.cheapest-price
product.rating-average
product.stream
product.search-keyword
product.states

Each one is wrapped in if ($message->allow(...)) inside the indexer. So if your import updates stock levels only, you can skip the expensive search keyword and cheapest price recalculation and keep the stock updater — per request, via one header.

The same thing in PHP: two context states

Not going through the Sync API? Writing via repositories in a command or subscriber? Same mechanics, different door. SyncService translates the header into a context state (SyncService.php:52-55) — and you can set that state yourself:

use Shopware\Core\Framework\DataAbstractionLayer\Indexing\EntityIndexerRegistry;

// push indexing to the queue
$context->addState(EntityIndexerRegistry::USE_INDEXING_QUEUE);

// or: skip indexing entirely
$context->addState(EntityIndexerRegistry::DISABLE_INDEXING);

$this->productRepository->upsert($payload, $context);

The core itself does this in places you’d expect once you know it exists: the thumbnail service disables indexing during bulk thumbnail generation, the customer registration route queues it to keep the response fast.

One warning from experience: if you use DISABLE_INDEXING, you own the rebuild. Which brings us to the third tool.

dal:refresh:index — with --skip and --only

The rebuild command everyone knows. The options almost nobody knows:

$ bin/console dal:refresh:index --help
  --use-queue       Ignore cache and force generation
  --skip=SKIP       Comma separated list of indexer names to be skipped
  --only=ONLY       Comma separated list of indexer names to be generated

So after a disable-indexing import you don’t have to reindex the whole shop. You rebuild exactly what you touched:

bin/console dal:refresh:index --only=product.indexer --use-queue

--use-queue chops the work into messages for your workers instead of blocking the CLI for an hour. And --skip works here too, including sub-updaters — the lists get passed straight into the same EntityIndexerRegistry::index() we saw above (EntityIndexerRegistry.php:79).

The recipe

Putting it together, a large import looks like this:

  1. Send your sync operations with indexing-behavior: disable-indexing (fastest) or use-queue-indexing (safest).
  2. If you only need parts of the indexing, use indexing-skip with specific updater names instead.
  3. Afterwards: bin/console dal:refresh:index --only=product.indexer --use-queue.
  4. Watch your workers do the heavy lifting while your import is already done.

On a real project this turned a multi-hour nightly import into minutes of writing plus background indexing. Same data, same hardware — we just stopped recalculating cheapest prices 50,000 times for no reason.

TL;DR

The Sync API reads two undocumented headers: indexing-behavior (use-queue-indexing / disable-indexing) and indexing-skip (comma-separated indexer and sub-updater names like product.cheapest-price). In PHP, $context->addState(EntityIndexerRegistry::DISABLE_INDEXING) does the same. Rebuild selectively with dal:refresh:index --only=... --use-queue. Your imports will thank you.

Next up: sw-expect-packages — the header that lets your API integration fail fast (and loudly) when the shop doesn’t have the plugins it expects.

If you have questions or war stories about slow imports, drop me an email!


Found in Shopware 6.7.0.0: header handling in Framework/Api/Controller/SyncController.php:48-53, state translation in Framework/Api/Sync/SyncService.php:47-55, states and skip logic in Framework/DataAbstractionLayer/Indexing/EntityIndexerRegistry.php:25-29,79-90, sub-updater constants in Content/Product/DataAbstractionLayer/ProductIndexer.php:34-44, command options in Framework/DataAbstractionLayer/Command/RefreshIndexCommand.php:45-48.