Updating 5,000 nodes in a single PHP request is a recipe for a timeout, a memory exhaustion error, or both — even if the update logic for one node is trivially fast. The Batch API exists exactly for this: it splits a large operation into many small chunks, each running in its own HTTP request, with a visible progress bar so the person who triggered it isn't left staring at a blank screen wondering if the site has crashed. This lesson applies that pattern specifically to bulk entity operations.
What you'll learn in this lesson
- Why looping over thousands of entities in one request is the wrong shape, even for fast per-entity logic
- How to structure a batch operation that updates entities in bounded chunks
- How
$context['sandbox']lets a batch resume exactly where it left off across requests - Why you should load-modify-save one entity at a time inside a batch, not accumulate a giant array first
Defining the batch
$batch = [
'title' => t('Updating articles...'),
'operations' => [
['\Drupal\mymodule\BatchOperations::updateArticles', []],
],
'finished' => '\Drupal\mymodule\BatchOperations::batchFinished',
];
batch_set($batch);
Notice there's exactly one entry in operations, not one per entity. A batch operation callback isn't "run once per item" — it's "run once per HTTP request," and it's the callback's own job to track its progress across those requests and process a bounded chunk each time.
The operation callback: process a chunk, track progress in $context['sandbox']
public static function updateArticles(array &$context) {
$storage = \Drupal::entityTypeManager()->getStorage('node');
if (!isset($context['sandbox']['progress'])) {
$ids = $storage->getQuery()
->condition('type', 'article')
->accessCheck(FALSE)
->execute();
$context['sandbox']['ids'] = array_values($ids);
$context['sandbox']['progress'] = 0;
$context['sandbox']['max'] = count($ids);
}
$chunk = array_slice($context['sandbox']['ids'], $context['sandbox']['progress'], 25);
foreach ($chunk as $nid) {
$node = $storage->load($nid);
$node->set('field_needs_review', FALSE);
$node->save();
$context['sandbox']['progress']++;
$context['results'][] = $nid;
}
$context['message'] = t('Updated @count of @total articles.', [
'@count' => $context['sandbox']['progress'],
'@total' => $context['sandbox']['max'],
]);
if ($context['sandbox']['progress'] < $context['sandbox']['max']) {
$context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['max'];
}
}
The first call populates $context['sandbox'] with the full list of IDs to process and a progress counter — this only happens once, because $context['sandbox'] survives across every subsequent call to this same operation. Each call after that processes one bounded chunk (25 nodes here) and updates the counter. Setting $context['finished'] to a fraction below 1 tells the batch engine "call me again" — leaving it unset (or at 1) tells it this operation is done.
The finished callback
public static function batchFinished($success, $results, $operations) {
if ($success) {
\Drupal::messenger()->addMessage(t('Updated @count articles.', [
'@count' => count($results),
]));
}
else {
\Drupal::messenger()->addError(t('Batch finished with errors.'));
}
}
This runs exactly once, after every chunk across every request has completed. $results is the accumulated array you pushed onto in the operation callback — here, every node ID that was actually updated — giving you a real count to report rather than guessing.
Quick check: why does the chunk size matter (25 here, not 1 and not 1,000)? (Answer: too small, and you pay the overhead of a full HTTP request/bootstrap cycle for almost no work each time, making the whole batch slower overall; too large, and a single chunk risks hitting PHP's execution time limit before it finishes, defeating the entire point of chunking. 25-100 is a reasonable starting range for typical entity saves — tune it based on how expensive your per-entity logic actually is.)
Key takeaways
- Never loop over thousands of entities in a single request — use the Batch API to split the work across many bounded requests instead.
- A batch operation callback runs once per HTTP request, not once per item; it's responsible for tracking its own progress and processing a fixed-size chunk each time.
$context['sandbox']persists across calls to the same operation — use it to store the full ID list and a progress counter on the first call.- Load, modify, and save entities one at a time inside the chunk loop to keep peak memory bounded, rather than accumulating loaded entities in an array first.
- Choose a chunk size that balances per-request overhead against the risk of hitting PHP's execution time limit — 25-100 entities per chunk is a reasonable starting point for typical saves.
Coming up next
You've now covered the full "Entity and Field Optimization" arc: loading only what you need, tagging it so it caches correctly, rendering it efficiently, querying it fast, and batching bulk changes safely. Next, we shift focus to the server itself — configuring PHP OpCache so all of this application-level work runs on already-compiled code instead of re-parsing PHP on every single request.