04

Running Specific Update Hooks in Drupal 11

Ever deployed an update hook that failed halfway through, fixed the bug, and wondered how to re-run just that one hook without starting from scratch? Or needed to test a specific update hook during development without running all pending updates? There's a targeted approach for this.

What you will learn:

  • What update hooks are and when they run
  • How to execute a specific update hook using Drush
  • Testing update hooks during development
  • Handling failed updates and re-running them safely
  • Best practices for writing reliable update hooks

Prerequisites:

  • Basic understanding of Drupal modules and .install files
  • Familiarity with Drush commands
  • A working Drupal 11 environment (examples use DDEV)
  • Understanding of PHP functions

Estimated time: 20 minutes


Understanding Update Hooks

What Are Update Hooks?

Update hooks are special functions in a module's .install file that execute database changes, configuration updates, or other one-time operations when updating your Drupal site. They follow the naming pattern hook_update_N() where N is an integer version number.

Example from web/modules/custom/my_module/my_module.install:

<?php

/**
 * @file
 * Install, update and uninstall functions for My Module.
 */

declare(strict_types=1);

/**
 * Add a new field to store user preferences.
 */
function my_module_update_9001(): void {
  $config_factory = \Drupal::configFactory();
  $config = $config_factory->getEditable('my_module.settings');
  $config->set('enable_preferences', TRUE);
  $config->save();
}

/**
 * Migrate legacy data to new structure.
 */
function my_module_update_9002(): string {
  $database = \Drupal::database();

  // Perform migration logic here
  $count = $database->update('my_module_data')
    ->fields(['migrated' => 1])
    ->condition('migrated', 0)
    ->execute();

  return t('Migrated @count records.', ['@count' => $count]);
}

When Do Update Hooks Run?

Update hooks execute in these scenarios:

  1. Via Drush: Running drush updb -y (update database)
  2. Via UI: Visiting /update.php in your browser
  3. During Deployment: Automated in CI/CD pipelines

Drupal tracks which updates have run in the key_value table and only executes hooks with higher version numbers than the last recorded update.


Running a Specific Update Hook

The Command

To run a specific update hook directly, use this Drush command:

drush php-eval "module_load_install('MYMODULE'); MYMODULE_update_NUMBER();"

Replace:

  • MYMODULE with your module's machine name
  • NUMBER with the update hook number

Real-World Examples

Example 1: Testing a Configuration Update

You have this update hook:

/**
 * Enable the new analytics feature.
 */
function my_module_update_9003(): void {
  $config = \Drupal::configFactory()->getEditable('my_module.settings');
  $config->set('analytics_enabled', TRUE);
  $config->save();
}

Run it directly:

# Via DDEV
ddev drush php-eval "module_load_install('my_module'); my_module_update_9003();"

# Direct Drush (production)
vendor/bin/drush php-eval "module_load_install('my_module'); my_module_update_9003();"

Example 2: Re-running a Failed Update with Return Value

If your update hook returns a message:

/**
 * Rebuild node access records.
 */
function my_module_update_9004(): string {
  node_access_rebuild();
  return t('Node access records have been rebuilt.');
}

Capture the output:

ddev drush php-eval "module_load_install('my_module'); echo my_module_update_9004();"

Output:

Node access records have been rebuilt.

Example 3: Update Hook with Database Changes

/**
 * Add status column to custom table.
 */
function my_module_update_9005(): void {
  $schema = \Drupal::database()->schema();

  if (!$schema->fieldExists('my_module_data', 'status')) {
    $schema->addField('my_module_data', 'status', [
      'type' => 'varchar',
      'length' => 20,
      'not null' => TRUE,
      'default' => 'active',
      'description' => 'Record status',
    ]);
  }
}

Run and verify:

# Run the update
ddev drush php-eval "module_load_install('my_module'); my_module_update_9005();"

# Verify the field was added
ddev drush sqlq "DESCRIBE my_module_data;"

Common Use Cases

Scenario 1: Failed Update During Deployment

Problem: Update hook my_module_update_9007() failed in production due to a bug. You've fixed the code and need to re-run just that hook.

Solution:

  1. Fix the bug in your update hook code
  2. Deploy the fixed code
  3. Run the specific update:
ssh user@production.server
cd /var/www/html/site
vendor/bin/drush php-eval "module_load_install('my_module'); my_module_update_9007();"
  1. Mark it as complete in Drupal's tracking (if successful):
vendor/bin/drush php-eval "\Drupal::keyValue('system.schema')->set('my_module', 9007);"

Warning: Only mark as complete if you're certain the update succeeded. Drupal won't run it again automatically otherwise.

Scenario 2: Testing During Development

Problem: You're writing a complex update hook and want to test it multiple times without incrementing the version number.

Workflow:

  1. Write your update hook (e.g., my_module_update_9010())
  2. Test it:
ddev drush php-eval "module_load_install('my_module'); my_module_update_9010();"
  1. Check results, modify code, repeat step 2
  2. Once working, run officially:
ddev drush updb -y

Scenario 3: Debugging Update Hook Logic

Problem: Update hook isn't behaving as expected. You need to step through it.

Solution: Add debugging output:

/**
 * Complex data migration.
 */
function my_module_update_9008(): string {
  $database = \Drupal::database();

  // Debug: Show what we're about to process
  $count = $database->select('old_table', 'o')
    ->countQuery()
    ->execute()
    ->fetchField();

  \Drupal::logger('my_module')->notice('Processing @count records', ['@count' => $count]);

  // Actual migration logic
  // ...

  return t('Processed @count records.', ['@count' => $count]);
}

Run with output:

ddev drush php-eval "module_load_install('my_module'); echo my_module_update_9008();"

# Check logs
ddev drush watchdog:show --type=my_module

Important Considerations

Update Hook Numbering

Drupal uses a specific numbering scheme:

  • Drupal 8/9: Format 80XX or 90XX (e.g., 8001, 9001)
  • Drupal 10/11: Format 100XX or 110XX (e.g., 10001, 11001)
  • Sequential: Each module maintains its own sequence

Best practice: Start Drupal 11 modules at 11001 and increment sequentially.

Idempotency

Update hooks should be idempotent - safe to run multiple times without causing problems.

Bad (not idempotent):

function my_module_update_9009(): void {
  $schema = \Drupal::database()->schema();
  // This will fail if run twice!
  $schema->addField('my_table', 'new_field', [...]);
}

Good (idempotent):

function my_module_update_9009(): void {
  $schema = \Drupal::database()->schema();

  // Check first
  if (!$schema->fieldExists('my_table', 'new_field')) {
    $schema->addField('my_table', 'new_field', [...]);
  }
}

Dependency Handling

Update hooks can depend on other updates running first. Declare dependencies using hook_update_dependencies().

/**
 * Implements hook_update_dependencies().
 */
function my_module_update_dependencies(): array {
  // Indicate that my_module_update_9011() must run AFTER my_module_update_9005()
  // and other_module_update_9003().
  $dependencies['my_module'][9011] = [
    'my_module' => 9005,
    'other_module' => 9003,
  ];

  return $dependencies;
}

Example update function with dependencies:

/**
 * Migrate user data to new table structure.
 *
 * Depends on update 9005 creating the new table.
 */
function my_module_update_9011(): void {
  // This runs only after dependencies complete
  $database = \Drupal::database();
  // ... migration logic ...
}

Note: When running updates manually with php-eval, dependencies are not checked automatically. Ensure prerequisite updates have run first.


Best Practices

Do

  • Test in a safe environment first - Never run untested updates directly in production
  • Make hooks idempotent - Always check if changes already exist before applying them
  • Add descriptive docblocks - Explain what the update does and why
  • Return status messages - Use return values to report what happened
  • Use transactions for database changes - Wrap complex operations in transactions
  • Log important operations - Use \Drupal::logger() for debugging

Avoid

  • Assuming state - Don't assume content or configuration exists
  • Skipping error handling - Always wrap risky operations in try-catch blocks
  • Hardcoding values - Use configuration where possible
  • Running destructive operations without confirmation - Especially for data deletion
  • Forgetting to test rollback scenarios - Have a backup plan

Example: Well-Written Update Hook

<?php

declare(strict_types=1);

/**
 * Migrate article content from legacy field to new paragraph field.
 *
 * Converts old 'field_legacy_content' to 'field_content_paragraphs'.
 * This is a one-time migration for the content restructuring project.
 *
 * Dependencies: Requires paragraphs module update 11001 to run first.
 * See my_module_update_dependencies() for dependency declaration.
 */
function my_module_update_11002(): string {
  $entity_type_manager = \Drupal::entityTypeManager();
  $logger = \Drupal::logger('my_module');

  try {
    $storage = $entity_type_manager->getStorage('node');
    $query = $storage->getQuery()
      ->condition('type', 'article')
      ->exists('field_legacy_content')
      ->accessCheck(FALSE);

    $nids = $query->execute();

    if (empty($nids)) {
      return t('No articles found requiring migration.');
    }

    $count = 0;
    foreach ($storage->loadMultiple($nids) as $node) {
      // Check if already migrated (idempotency check)
      if (!$node->get('field_content_paragraphs')->isEmpty()) {
        continue;
      }

      // Perform migration
      $legacy_value = $node->get('field_legacy_content')->value;

      // Create paragraph entity
      // ... migration logic ...

      $node->save();
      $count++;
    }

    $logger->notice('Migrated @count articles', ['@count' => $count]);
    return t('Successfully migrated @count articles.', ['@count' => $count]);
  }
  catch (\Exception $e) {
    $logger->error('Migration failed: @message', ['@message' => $e->getMessage()]);
    throw new \Exception('Article migration failed: ' . $e->getMessage());
  }
}

Debugging Failed Updates

Check Update Status

See which updates have run and which are pending:

# Show pending updates
ddev drush updatedb:status

# Show installed schema versions
ddev drush php-eval "print_r(\Drupal::keyValue('system.schema')->getAll());"

Reset Update Status

If you need to mark an update as not yet run (to allow it to run again via drush updb):

# Set module to earlier version
ddev drush php-eval "\Drupal::keyValue('system.schema')->set('my_module', 9005);"

# Now update 9006 and later will run again
ddev drush updb -y

Danger: This can cause issues if the update already made changes. Only do this if you understand the implications.

View Update Registry

# See all registered update hooks
ddev drush php-eval "module_load_install('my_module'); print_r(get_defined_functions()['user']);" | grep update

Common Errors

Error: "Module X is not installed"

# Verify module is enabled
ddev drush pml | grep my_module

# Enable if needed
ddev drush en my_module -y

Error: "Call to undefined function"

Problem: Drupal hasn't loaded the .install file.

Solution:

# Explicitly load it
ddev drush php-eval "module_load_install('my_module'); my_module_update_9001();"

Error: "Schema version already exists"

Problem: Drupal thinks the update already ran.

Solution: Check if it actually did:

ddev drush php-eval "echo \Drupal::keyValue('system.schema')->get('my_module');"

If you need to run it again, either reset the schema version or add a new update hook with a higher number.


Alternative: Using Drush's Update Runner

While php-eval is useful for testing, Drush provides a safer way to run updates that respects dependencies:

# Run all pending updates
ddev drush updb -y

This approach:

  • Checks dependencies
  • Updates the schema registry automatically
  • Provides proper error handling
  • Shows progress output

Note: Unlike php-eval, there is no built-in way to run just a single specific update via Drush. You can only run all pending updates together.

Quick Reference

Essential Commands

Command Purpose
drush php-eval "module_load_install('MODULE'); MODULE_update_N();" Run specific update hook
drush updb -y Run all pending updates
drush updatedb:status Show pending updates
drush php-eval "\Drupal::keyValue('system.schema')->get('MODULE');" Check current schema version
drush php-eval "\Drupal::keyValue('system.schema')->set('MODULE', N);" Set schema version

Next Steps

Continue learning about Drupal updates and deployment:

  • Post Update Hooks - Using hook_post_update_NAME() for configuration updates
  • Deploy Hooks - Drupal 10+ hook_deploy_NAME() for deployment tasks
  • Batch API - Processing large datasets in update hooks
  • Configuration Management - Coordinating config updates with code changes

Further Reading:


Summary

You've learned how to:

  • ✅ Execute specific update hooks using drush php-eval
  • ✅ Test updates during development without affecting the registry
  • ✅ Handle failed updates and re-run them safely
  • ✅ Write idempotent, well-structured update hooks
  • ✅ Debug common update hook problems

Key takeaway: While drush updb is the standard way to run updates, directly invoking specific hooks with php-eval is invaluable for testing, debugging, and handling edge cases during development and deployment.