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.
Table of Contents
- 1. Synchronous or asynchronous: which mass action variant fits
- 2. Declaring a mass action in listing.xml
- 3. A custom controller for the bulk action
- 4. Selection handling: selected versus excluded
- 5. Fundamentals of the Magento bulk API
- 6. Registering a custom consumer and wiring the queue topic
- 7. Error handling and retry strategy
- 8. Scaling: when synchronous, when asynchronous makes sense
- 9. ACL hardening and testing the bulk processing
- 10. Summary
- 11. FAQ
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.