As Drupal sites grow and evolve, so do their content and administrative needs. A common task is updating the text format for a specific field across many nodes. This can happen when you're migrating from a restricted format like basic_html to a more powerful one like full_html to unlock new features or clean up old content. Doing this manually is a tedious and error-prone process, but a simple Drush script can automate it safely and efficiently.
This post will walk you through a robust and well-commented Drush script designed for this very purpose. We'll also cover some of the common pitfalls you might encounter and how to fix them, drawing from real-world debugging scenarios.
1. The Purpose of the Script
The core function of this script is to find all nodes of a specific content type and programmatically change the text format of one or more of their text fields. For example, it can find all "article", "story", and "page" nodes and update their body field and custom fields like field_second_body and field_third_body from basic_html to full_html.
The script uses Drupal's official Entity API to perform these changes. This is a critical best practice because it ensures that all necessary database tables, including revision tables (e.g., node__body and node_revision__body), are updated correctly, maintaining data integrity and revision history.
2. Script Configuration and Logic
Before running the script, you'll need to configure a few key variables at the top of the file.
// --- SCRIPT CONFIGURATION ---
// Define the content types you want to target.
$content_types = ['article', 'story', 'page'];
// Define the fields whose text format you want to update.
$fields_to_update = ['body', 'field_second_body', 'field_third_body'];
// Set the current format you want to change FROM.
const CURRENT_FORMAT = 'basic_html';
// Set the target format you want to change TO.
const TARGET_FORMAT = 'full_html';
// Set to TRUE to force the change regardless of the current format.
const FORCE_FORMAT_CHANGE = FALSE;
// How many nodes to process before clearing the entity cache to prevent memory issues.
const BATCH_SIZE = 50;
// --- END SCRIPT CONFIGURATION ---
The script's logic iterates through each configured content type, loads each node, checks if its specified fields have the CURRENT_FORMAT, and, if they do, updates them to the TARGET_FORMAT. After saving the node, it logs the change and moves on to the next one. This approach is highly efficient for large-scale content updates.
3. How to Run the Script
- Backup Your Database! This is a critical step. Always perform a full database backup before running any script that modifies content.
- Save the file: Save the complete script below as a
.phpfile (e.g.,update_text_formats.php) in your Drupal webroot directory. - Configure the script: Open the file and adjust the configuration variables to match your site's needs.
- Execute via Drush: Open your terminal, navigate to your Drupal root, and run the command:
bash drush scr update_text_formats.php - Confirm: The script will first ask for confirmation. Read the message carefully and type
yesto proceed.
4. Common Errors and Their Solutions
This script is built to be resilient against a few common issues you might face.
"Call to undefined method Drush\Drush::confirm()"
This error occurs when a script running via
drush scrcan't find or access Drush's input/output services. The script's final version provides a more robust solution by first trying to get the service in a few different ways and, if all else fails, falling back to a basic PHPreadline()prompt."Entity queries must explicitly set whether the query should be access checked or not."
This is a key requirement in modern Drupal versions. By default, entity queries check user permissions. In a Drush script, you usually want to bypass this to ensure you can find and update all nodes. The fix is to add
->accessCheck(FALSE)to the entity query, as included in the final script.
5. The Complete Drush Script
Here is the final, corrected version of the script, with added comments to explain each key step. Copy this into a new .php file and follow the instructions above.
get('update_text_formats_script');
/** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */
$entity_type_manager = \Drupal::service('entity_type.manager');
// --- SCRIPT CONFIGURATION ---
// Define the content types you want to target.
$content_types = ['article', 'story', 'page'];
// Define the fields whose text format you want to update.
$fields_to_update = ['body', 'field_second_body', 'field_third_body'];
// Set the current format you want to change FROM.
const CURRENT_FORMAT = 'basic_html';
// Set the target format you want to change TO.
const TARGET_FORMAT = 'full_html';
// Set to TRUE to force the change regardless of the current format.
const FORCE_FORMAT_CHANGE = FALSE;
// How many nodes to process before clearing the entity cache to prevent memory issues.
const BATCH_SIZE = 50;
// --- END SCRIPT CONFIGURATION ---
$logger->info('Starting text format update script...');
$logger->info('Current format to target: {current_format}', ['current_format' => CURRENT_FORMAT]);
$logger->info('Target format: {target_format}', ['target_format' => TARGET_FORMAT]);
$logger->info('Force format change: {force_change}', ['force_change' => (FORCE_FORMAT_CHANGE ? 'Yes' : 'No')]);
// Drush confirmation prompt
$confirmation_message = dt('Are you sure you want to update text formats for fields (@fields) in content types (@types)? THIS ACTION IS IRREVERSIBLE WITHOUT A DATABASE BACKUP.', [
'@fields' => implode(', ', $fields_to_update),
'@types' => implode(', ', $content_types),
]);
$confirmed = FALSE;
if ($io) {
// Use Drush's IO service if successfully retrieved.
$confirmed = $io->confirm($confirmation_message);
} else {
// Fallback to basic readline for confirmation if Drush IO is not available.
fwrite(STDOUT, $confirmation_message . ' (y/n): ');
$response = trim(fgets(STDIN));
$confirmed = (strtolower($response) === 'y' || strtolower($response) === 'yes');
}
if (!$confirmed) {
$logger->warning('Script aborted by user.');
return;
}
$updated_nodes_count = 0;
$skipped_nodes_count = 0;
$total_nodes_processed = 0;
// Loop through each configured content type.
foreach ($content_types as $content_type_id) {
$logger->info("Processing nodes of type: {type}", ['type' => $content_type_id]);
// Use an entity query to get all Node IDs for the current content type.
$nids = \Drupal::entityQuery('node')
->condition('type', $content_type_id)
->accessCheck(FALSE) // REQUIRED: Explicitly disable access checks for Drush scripts.
->execute();
if (empty($nids)) {
$logger->info("No nodes found for content type: {type}", ['type' => $content_type_id]);
continue;
}
$total_nodes_for_type = count($nids);
$processed_for_type = 0;
// Loop through each node ID to load and process the node.
foreach ($nids as $nid) {
$node = Node::load($nid);
if (!$node) {
$logger->warning('Could not load node with ID: {nid}. Skipping.', ['nid' => $nid]);
continue;
}
$node_changed = FALSE;
$fields_changed_on_node = [];
// Loop through each field to check and update its format.
foreach ($fields_to_update as $field_name) {
if ($node->hasField($field_name)) {
$field_items = $node->get($field_name);
if (!$field_items->isEmpty()) {
$item = $field_items->first(); // Get the first (and likely only) item of the field.
// Check if we should change the format based on configuration.
if (FORCE_FORMAT_CHANGE || ($item->format == CURRENT_FORMAT)) {
// Only make a change if the format is actually different to avoid unnecessary saves.
if ($item->format !== TARGET_FORMAT) {
$item->format = TARGET_FORMAT;
$node_changed = TRUE;
$fields_changed_on_node[] = $field_name;
}
}
}
}
}
// Save the node if any field was changed.
if ($node_changed) {
try {
$node->save();
$updated_nodes_count++;
$logger->notice('Node {nid} ({title}): Updated format for fields: {fields}.', [
'nid' => $nid,
'title' => $node->label(),
'fields' => implode(', ', $fields_changed_on_node),
]);
} catch (\Exception $e) {
$logger->error('Error saving node {nid}: {message}', [
'nid' => $nid,
'message' => $e->getMessage(),
]);
}
} else {
$skipped_nodes_count++;
$logger->debug('Node {nid} ({title}) skipped. No relevant field format needed changing or field not present.', ['nid' => $nid, 'title' => $node->label()]);
}
$total_nodes_processed++;
$processed_for_type++;
// Periodically clear the entity cache to prevent memory exhaustion on large sites.
if ($total_nodes_processed % BATCH_SIZE === 0) {
$entity_type_manager->getStorage('node')->resetCache();
$logger->info("Processed {count} total nodes. Clearing node entity cache...", ['count' => $total_nodes_processed]);
}
// Log progress for the current content type every 100 nodes.
if ($processed_for_type % 100 === 0) {
$logger->info("Processed {count}/{total} nodes for type {type}.", [
'count' => $processed_for_type,
'total' => $total_nodes_for_type,
'type' => $content_type_id,
]);
}
}
}
// Clear all Drupal caches after all operations are complete to ensure changes are visible.
$logger->info('Script finished. Clearing Drupal caches...');
drupal_flush_all_caches();
$logger->info('--- Script Summary ---');
$logger->info('Total nodes processed across all types: {total}.', ['total' => $total_nodes_processed]);
$logger->info('Nodes where at least one field format was updated: {updated}.', ['updated' => $updated_nodes_count]);
$logger->info('Nodes skipped (no format change needed or field not present): {skipped}.', ['skipped' => $skipped_nodes_count]);
$logger->info('Remember to test thoroughly on a development environment first.');
?>