Mass Actions in Magento 2 Admin Grids: Building Custom Bulk Operations
AI generated
M2
di.xml
Magento 2 · Admin UI Components
Custom Mass Actions with the Bulk API
From massaction declaration to asynchronous processing via the AsynchronousOperations queue

A custom bulk action in an admin grid is quick to declare, but without the bulk API it rarely scales past a few hundred records. Understanding how the massaction declaration, the controller, and the AsynchronousOperations queue work together lets you build mass exports, mass status changes, or more complex bulk processing that still runs reliably at tens of thousands of selected rows.

12 min read Mass Actions Bulk API AsynchronousOperations RabbitMQ Consumer

1. Synchronous or asynchronous: which mass action variant fits

Magento supports two fundamentally different ways of processing a mass action. The simple path calls a single controller request synchronously for all selected rows, fully processing the action within the normal request lifetime. That's entirely sufficient for small grids and simple operations like a status change on a handful of rows, and comes with far less infrastructure overhead than the second path.

As soon as several thousand rows can be selected or per-row processing itself is expensive, for example an export with an external API call per product, the synchronous approach becomes a problem: the PHP process times out, the admin browser waits for a response that never arrives, and in the worst case the operation aborts midway with no way to tell which rows were already processed. For that case, Magento ships the bulk API, which offloads actual processing to a queue.

2. Declaring a mass action in listing.xml

A new mass action is declared inside the massaction node of listing.xml, usually as a child of the existing actions column. Every action needs a unique type, a label, a target URL, and optionally a confirmation prompt, which makes sense ahead of irreversible operations like a mass delete. The type value is later used client-side to tell the server which action to run against the selected rows.

For a bulk export action, the pure XML declaration alone is already enough to make the button appear in the grid, the actual logic lives entirely in the referenced controller. It's important that the URL points to an adminhtml controller with the matching ACL resource entry, otherwise users without the corresponding permission still see the button but hit an access denial when they click it.


<!-- app/code/Mironsoft/BulkOperations/view/adminhtml/ui_component/product_listing.xml -->
<massaction name="listing_massaction">
    <action name="bulk_export">
        <settings>
            <type>bulk_export</type>
            <label translate="true">Start Mass Export</label>
            <url path="mironsoft_bulkoperations/product/massexport"/>
            <confirm>
                <title translate="true">Mass Export</title>
                <message translate="true">
                    Start a background export for the selected products?
                </message>
            </confirm>
        </settings>
    </action>
</massaction>

3. A custom controller for the bulk action

The controller behind a mass action typically extends Magento\Ui\Controller\Adminhtml\Massaction, which already encapsulates most of the selection logic. The central building block is evaluating the filter context via MassactionFilter, which reads from the request either a list of explicitly selected IDs or, in the select all case, an active filter set that only gets resolved against the collection at runtime.

That second case is exactly why a naive controller that just reads an ID list from the request fails on very large result sets: if a user selects all rows of a filtered grid with fifty thousand matches, an ID list as a request parameter would blow past the maximum request size. MassactionFilter solves this by transmitting the filter conditions themselves instead, leaving the actual ID resolution to the server.


<?php

declare(strict_types=1);

namespace Mironsoft\BulkOperations\Controller\Adminhtml\Product;

use Magento\Ui\Component\MassAction\Filter;
use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory;
use Magento\Backend\App\Action;
use Magento\Framework\Controller\ResultFactory;
use Mironsoft\BulkOperations\Model\BulkExportScheduler;

/**
 * Starts the asynchronous mass export for the products
 * selected or filtered in the grid.
 */
class MassExport extends Action
{
    public const ADMIN_RESOURCE = 'Mironsoft_BulkOperations::export';

    /**
     * @param Action\Context $context Standard adminhtml context
     * @param Filter $massactionFilter Resolves selection or filter set into a collection
     * @param CollectionFactory $collectionFactory Builds the product collection
     * @param BulkExportScheduler $bulkExportScheduler Wraps the bulk API call
     */
    public function __construct(
        Action\Context $context,
        private readonly Filter $massactionFilter,
        private readonly CollectionFactory $collectionFactory,
        private readonly BulkExportScheduler $bulkExportScheduler,
    ) {
        parent::__construct($context);
    }

    /**
     * Executes the mass action and redirects back to the grid.
     *
     * @return \Magento\Framework\Controller\ResultInterface
     */
    public function execute()
    {
        $collection = $this->massactionFilter->getCollection($this->collectionFactory->create());
        $productIds = $collection->getAllIds();

        $bulkUuid = $this->bulkExportScheduler->schedule($productIds, (int) $this->getRequest()->getParam('store', 0));

        $this->messageManager->addSuccessMessage(
            __('Export for %1 products was started in the background.', count($productIds))
        );

        $resultRedirect = $this->resultFactory->create(ResultFactory::TYPE_REDIRECT);
        return $resultRedirect->setPath('catalog/product/index');
    }
}

4. Selection handling: selected versus excluded

Besides the classic selection of individual checkboxes, the UI component grid library also supports a select all mode with subsequent exclusion of individual rows. Internally, the frontend then submits an isExcludeMode flag together with a list of explicitly excluded IDs instead of a full list of included ones. MassactionFilter resolves both cases transparently, a custom controller doesn't need to handle this distinction itself as long as it consistently works through the filter instead of raw request parameters.

A common beginner mistake is reading the selected request parameter directly and completely overlooking the excluded case. In practice this leads to bulk operations that work fine in testing with a small, manually selected set of rows but seemingly produce wrong or empty results with select all, because the actual ID list was never resolved correctly.

5. Fundamentals of the Magento bulk API

The bulk API is built on the Magento_AsynchronousOperations module and separates three concepts: a bulk operation as the parent unit with its own UUID, individual operations as atomic units of work within that bulk operation, and a message queue consumer that asynchronously processes the operations from RabbitMQ or the configured queue backend. This separation makes it possible to track the progress of a very large mass operation granularly instead of treating it as one single long-running process.

The central entry point is BulkManagementInterface::scheduleBulk, which takes a unique bulk UUID, a list of OperationInterface objects, and a descriptive label. Each individual operation references a consumer name and carries the data needed for processing as a serialized string, typically a single product ID or a small batch of IDs rather than the entire result set in a single operation.


<?php

declare(strict_types=1);

namespace Mironsoft\BulkOperations\Model;

use Magento\AsynchronousOperations\Api\Data\OperationInterfaceFactory;
use Magento\Framework\Bulk\BulkManagementInterface;
use Magento\Framework\DataObject\IdentityGeneratorInterface;

/**
 * Creates a bulk operation for the asynchronous product export
 * and schedules one operation per product on the queue.
 */
class BulkExportScheduler
{
    private const CONSUMER = 'mironsoft.bulk.product.export';

    /**
     * @param BulkManagementInterface $bulkManagement Central bulk API
     * @param OperationInterfaceFactory $operationFactory Creates individual operations
     * @param IdentityGeneratorInterface $identityGenerator Generates unique UUIDs
     */
    public function __construct(
        private readonly BulkManagementInterface $bulkManagement,
        private readonly OperationInterfaceFactory $operationFactory,
        private readonly IdentityGeneratorInterface $identityGenerator,
    ) {
    }

    /**
     * Schedules the asynchronous export of the given product IDs.
     *
     * @param int[] $productIds Product IDs to export
     * @param int $storeId Store view for the export
     * @return string The generated bulk UUID
     */
    public function schedule(array $productIds, int $storeId): string
    {
        $bulkUuid = $this->identityGenerator->generateId();
        $operations = [];

        foreach ($productIds as $productId) {
            $operations[] = $this->operationFactory->create([
                'data' => [
                    'bulk_uuid' => $bulkUuid,
                    'topic_name' => self::CONSUMER,
                    'serialized_data' => json_encode([
                        'product_id' => $productId,
                        'store_id' => $storeId,
                    ]),
                    'status' => 0,
                ],
            ]);
        }

        $this->bulkManagement->scheduleBulk($bulkUuid, $operations, __('Mass Export'));

        return $bulkUuid;
    }
}

6. Registering a custom consumer and wiring the queue topic

For scheduled operations to actually get processed, the module needs three interlocking declarations: a topic in communication.xml, a consumer in queue_consumer.xml, and the binding of the topic to a queue in queue_topology.xml. A common misconception is assuming that registering a consumer alone is enough, but the consumers_runner cron job also needs to run regularly, or a persistent consumer process needs to be started manually via bin/magento queue:consumers:start, otherwise operations pile up unprocessed in the database table.

The consumer itself implements a single method that receives an OperationInterface instance, decodes the serialized payload, performs the actual business processing, and sets the operation's status to succeeded or failed via OperationManagementInterface. This last step is frequently forgotten in practice, with the result that operations are shown as permanently open in the bulk status widget even though the business processing finished long ago.


<!-- app/code/Mironsoft/BulkOperations/etc/communication.xml -->
<topic name="mironsoft.bulk.product.export" request="Magento\AsynchronousOperations\Api\Data\OperationInterface"/>

<!-- app/code/Mironsoft/BulkOperations/etc/queue_consumer.xml -->
<consumer name="mironsoft.bulk.product.export"
          queue="mironsoft.bulk.product.export"
          connection="db"
          handler="Mironsoft\BulkOperations\Model\Consumer\ExportConsumer::process"/>

<!-- app/code/Mironsoft/BulkOperations/etc/queue_topology.xml -->
<exchange name="magento" type="topic" connection="db">
    <binding id="MironsoftBulkExport" topic="mironsoft.bulk.product.export"
             destinationType="queue" destination="mironsoft.bulk.product.export"/>
</exchange>

7. Error handling and retry strategy

OperationInterface distinguishes four status values: open, in progress, successfully completed, and failed, where failed operations are further split into retriably failed and not retriably failed. A transient problem, such as an external service being briefly unreachable during export, should be marked retriably failed so that a later run automatically retries the operation, while a permanent problem like an invalid product ID should be marked not retriably failed to avoid endless retry attempts.

For visibility in the admin area, Magento already ships a bulk notification that surfaces failed operations in the notification area. For business-critical bulk operations it's worth adding a dedicated status page that queries the progress of a running bulk UUID via OperationRepositoryInterface and shows the user a readable summary instead of the fairly technical default view.

8. Scaling: when synchronous, when asynchronous makes sense

The bulk API isn't a cure-all, it comes with extra infrastructure complexity: a running consumer process, a working message queue configuration, and monitoring that catches when consumers have stopped for some reason and operations are piling up. For operations that reliably stay under a couple of seconds of processing time and rarely touch more than a few hundred rows at once, the synchronous path is often the more pragmatic, lower-maintenance choice.

With RabbitMQ as the queue backend, processing speed can additionally be scaled through the number of parallel consumer instances, which isn't possible to the same degree with the default database queue. For shops with regular, genuinely large bulk operations, such as nightly mass updates across tens of thousands of products, switching to RabbitMQ usually pays off on throughput grounds alone.

9. ACL hardening and testing the bulk processing

Every custom mass action needs its own ADMIN_RESOURCE constant in the controller and a matching entry in acl.xml, otherwise either no permission check applies at all or, worse, the action accidentally reuses the ACL resource of a different, already existing controller, making permissions unpredictable across admin roles. Especially for irreversible bulk operations like a mass delete, a dedicated, granular ACL resource is worth it instead of reusing a general catalog permission.

A pure controller test is rarely enough for automated testing, since actual processing happens asynchronously in the consumer. An integration test that creates a bulk operation via BulkManagementInterface, calls the consumer directly with a simulated operation, and then checks the resulting status covers the full chain from scheduling to processing without depending on a running RabbitMQ consumer in the test environment.

Approach Processing Scales to Typical Use Case
Synchronous mass action within the controller request a few hundred rows simple status change
Bulk API with DB queue asynchronous via cron consumer a few thousand rows mass export without high throughput needs
Bulk API with RabbitMQ asynchronous, multiple parallel consumers hundreds of thousands of rows regular large bulk jobs
Cron-based batch processing time-triggered, outside the grid unbounded, no user interaction nightly mass processing

Mironsoft

Magento development, module consulting, and system architecture

A Magento project that needs a second opinion or experienced execution?

We build custom Magento modules, advise on architecture decisions, and take on complex implementations, from service contract planning to production-ready deployment.

Architecture Consulting

Have module and system architecture thought through properly before you build.

Custom Module Development

Build custom Magento modules cleanly, following best practices.

Code Review & Audit

Have existing modules reviewed for performance, security, and maintainability.

10. Summary

Bulk Actions in Magento 2 Admin Grids: The Essentials

Declaration

massaction node in listing.xml with a unique type, label, and ACL-protected controller URL.

Controller

Use MassactionFilter instead of raw request parameters to correctly resolve selected and excluded modes.

Bulk API

BulkManagementInterface::scheduleBulk schedules operations, a consumer processes them asynchronously from the queue.

Operations

The consumer process needs to run persistently, set status codes correctly, and consider RabbitMQ for high throughput.

11. FAQ: Bulk Actions in Magento 2 Admin Grids: The Essentials

1When is the bulk API worth it over a simple synchronous mass action?
As soon as operations regularly exceed a few hundred rows or per-row processing takes more than milliseconds, for example through external API calls, synchronous processing becomes risky and the bulk API is the more reliable choice.
2Does the bulk consumer need to be started manually?
In production environments the consumer usually runs as a persistent process under a supervisor or gets triggered regularly by the consumers_runner cron job, a one-time manual start isn't enough for production operation.
3What happens to a bulk operation if the consumer process crashes?
Operations already marked in progress but not completed stay stuck in that status until a manual intervention or a recovery mechanism resets them, which is why monitoring for long-open operations makes sense.
4How does excluded mode technically differ from selected mode?
In excluded mode the frontend submits a flag plus a list of excluded IDs together with the active filter set, and MassactionFilter resolves the actually processed result set from that on the server.
5Is RabbitMQ strictly required for the bulk API?
No, Magento also supports a pure database queue without RabbitMQ, but for very large or frequent bulk operations with high throughput needs RabbitMQ is noticeably more performant and easier to scale.
6How do you show the user the progress of a running bulk operation?
Magento ships a default bulk notification in the admin header, for business-critical operations it's worth adding a dedicated status page that queries progress via OperationRepositoryInterface.
7How should failed operations be handled sensibly?
Transient errors should be marked retriably failed so a later run retries them automatically, while permanent business errors should be marked not retriably failed to avoid endless retries.
8Can a bulk operation be canceled by a user?
By default Magento doesn't offer a direct cancel mechanism in the admin UI, but a custom controller can specifically mark open operations as not retriably failed via OperationRepositoryInterface to stop further processing.
9How many products should a single operation within a bulk operation cover?
A single product ID per operation maximizes granularity for progress reporting and error handling, for very large volumes a small batch of roughly ten to fifty IDs per operation can reduce per-message overhead.
10Is the bulk API also suitable for cron-based, non-user-initiated mass processing?
In principle yes, but for purely time-triggered jobs without an immediate user context a classic Magento cron job with its own batch logic is often simpler to operate, since it doesn't require additional queue infrastructure.