Drupal 11 OOP Hooks: Two Real Migrations and What Actually Broke

K

Authored on

Image

How long do you think it takes to move a custom module from procedural hooks to #[Hook] classes? An afternoon? That's what the tutorials make it look like. Copy the function body, paste it into a method, slap an attribute on top, done.

This August, two completely unrelated client projects went through that exact migration in the same month. One is an Acquia-hosted site on Drupal 11.4.4, the other a multi-tenant platform on Drupal 11.3.11. Different code, different teams, different reviewers. One of them took a little over three weeks from the first commit to a clean deploy. And the interesting part wasn't the copy-paste. It was everything around it.

Having two independent runs of the same migration side by side doesn't happen often, so I want to share what actually happened, broken parts included.

A quick primer, in case you haven't touched these yet

Since Drupal 11.1, you can implement hooks as methods on a class under src/Hook/, marked with a #[Hook('hook_name')] attribute. Drupal discovers those classes, registers them as autowired services, and calls them like any other hook implementation. No more mymodule_form_alter() living in a .module file and pulling services out of \Drupal::service().

Quick correction I ran into along the way: some early docs and tickets say this landed in 10.3. It didn't. Auto-discovery of OOP hooks is 11.1. If you're on 11.1 or newer, this is the default path now, and any new custom module written with procedural hooks is legacy code on day one.

Here's the clean version, from a small accessibility module on the Acquia site. Before:

function site_ada_page_attachments(array &$attachments) {
  $attachments['#attached']['library'][] = 'site_ada/site_ada_custom';
}
 
function site_ada_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {
  if ($form_id !== 'views_exposed_form') {
    return;
  }
  if (!empty($form['keys'])) {
    if (empty($form['keys']['#attributes']['aria-label']) && empty($form['keys']['#title_display'])) {
      $form['keys']['#attributes']['aria-label'] = t('Search');
    }
  }
}

After, in src/Hook/AdaHooks.php:

final class AdaHooks {
  use StringTranslationTrait;
 
  #[Hook('page_attachments')]
  public function pageAttachments(array &$attachments): void {
    $attachments['#attached']['library'][] = 'site_ada/site_ada_custom';
  }
 
  #[Hook('form_alter')]
  public function formAlter(array &$form, FormStateInterface $form_state, string $form_id): void {
    if ($form_id !== 'views_exposed_form') {
      return;
    }
    if (!empty($form['keys'])) {
      if (empty($form['keys']['#attributes']['aria-label']) && empty($form['keys']['#title_display'])) {
        $form['keys']['#attributes']['aria-label'] = (string) $this->t('Search');
      }
    }
  }
}

Sounds easy, right? That part is. Let's talk about the rest.

The Acquia site: 63 hooks, and a 500 nobody expected

The scope here was 15 custom modules, 30 hook classes, 63 hooks migrated in total. Install, update, and schema hooks stayed in their .install files, where they belong. The theme's roughly 87 procedural functions were explicitly left for a follow-up ticket. That was a deliberate call: scope the migration, don't try to boil the ocean in one PR.

The stale hook cache

I'll start with the one that surprised everybody. During the second review round, the environment got a fresh DDEV container rebuild. Before anyone ran drush cr, the site threw this:

HTTP 500: InvalidArgumentException: Class "site_readonly_entity_access" does not exist

Drupal's hook implementation cache was still pointing at the old procedural function name, and it was trying to resolve it as a class. The function was gone. The cache didn't know yet.

This is a genuinely new failure mode. Under the procedural model, renaming or removing a hook function never blew up like that. With OOP hooks, the discovery result is cached, and a stale cache means a broken site. So I'll say it as plainly as I can: after deploying this kind of change, drush cr is not optional. It's not "good practice" anymore. It's a hard requirement. Of everything in this article, this is the gotcha I'd bet you'll hit too.

Autowiring works... until it doesn't

Hook classes are autowired. Core interfaces like RouteMatchInterface or EntityTypeManagerInterface resolve by type without you doing anything. Custom services? Not necessarily:

Cannot autowire service ... argument "$processor" ... has no type-hint,
or the type-hint is not a class/interface that can be resolved.

The cause: the custom services were registered by ID in services.yml, not aliased to their class. The container has no idea which service you mean when all it gets is a class name. The fix is to be explicit on the constructor parameter:

public function __construct(
  #[Autowire(service: 'site_ai.alt_text_batch_processor')]
  private readonly AltTextBatchProcessor $processor,
) {}

Not hard, but it fails loudly at container build time, so you'll find out fast.

Form callbacks have to stay static

This one is easy to get wrong because the "wrong" version looks so natural inside a class:

// Don't.
$form['#validate'][] = [$this, 'validateNoJavascript'];

Form arrays get cached and serialized. Stuffing a live service instance in there is fragile at best. The pattern that matches what Drupal core does in its own Hook classes is a static callable that grabs the service back:

$form['#validate'][] = [static::class, 'validateNoJavascript'];
 
public static function validateNoJavascript(array &$form, FormStateInterface $form_state): void {
  \Drupal::service(self::class)->doValidateNoJavascript($form, $form_state);
}

Not everything named like a hook is a hook

There were two functions called site_readonly_form_validate() and site_readonly_form_submit(). They look like hooks. They smell like hooks. They were never wired to anything. Nobody ever added them to $form['#validate'] or $form['#submit'] in the procedural code.

A "helpful" migration would have wired them up. And that would have silently changed production behavior, blocking saves for a read-only user role that had been working fine for ages. So they were migrated as-is, unwired. A migration should preserve behavior, including the no-ops and the pre-existing weirdness, unless someone explicitly asks you to fix it.

The reviewer who sent it back

This is my favorite part of the whole story, and it has nothing to do with PHP.

The first round was behaviorally correct. And the reviewer still sent it back. Partly for insufficient manual testing, and partly because the mechanical move from procedural to OOP had stripped the comments that explained why some code existed: WCAG citations on the accessibility alters, a "temporarily disabled for testing" note, and the rationale behind an Acquia symlink false-positive workaround.

None of that affects runtime. All of it matters the next time someone opens that file and wonders if they can delete a weird-looking line. The second round ported that context into the new docblocks on purpose. A migration can be 100% behavior-preserving and still destroy institutional knowledge. I hadn't thought about it that way before, and I won't forget it now.

And here's the funny thing: it was the re-verification pass for that second round that uncovered the stale-cache 500. Sending it back paid off twice.

The one intentional change

Exactly one hook didn't come across identical. The cron() hook in the import forms module used to check method_exists($service, 'executeCron') before calling it. Now the service is type-injected, so the DI container guarantees it exists at compile time. The guard was dead code, so it was dropped. But it was flagged for reviewer sign-off instead of quietly shipped. "Same logic" and "same logic, minus provably dead defensive code" are not the same claim, and the PR said so.

How it was verified

This is the part I'd copy for any future migration:

  • A unit suite for the Hook classes: 61 tests, 133 assertions.
  • A kernel test, OopHookDiscoveryTest, that only checks whether the hooks are discovered, separate from what they do. OOP hook registration is new machinery, and it can fail to wire things up without saying a word.
  • Functional smoke tests for the accessibility and AMP hooks.
  • A hook-by-hook diff matrix: every one of the 63 migrated hooks compared against its procedural original on master and marked Same or Diff. Result: 62 Same, 1 documented Diff (the cron guard above).

And then it kept going

If you still think this is an afternoon job, here's the timeline. Initial migration and first review on July 28. Sent back on July 31. Final checklist green on August 4. Then two more rounds, on August 6 and August 20, of merging master into the long-lived branch and porting the new procedural hooks other people had added in the meantime.

And on August 21, after deploy, Acquia CI failed with a ServiceNotFoundException for masquerade and plugin.manager.views.join in minimal test installs, where those services simply don't exist. Locally in DDEV everything was fine, of course. The fix was making those dependencies soft-optional: @? service references plus nullable constructor parameters. That's the kind of thing that only shows up once the code leaves your laptop.

The multi-tenant platform: same conclusions, reached independently

The second project was smaller on the hooks side, and the migration was just one item in a much bigger Drupal 11 standards audit. Fifteen hook registrations moved into five classes: FormHooks, NodeHooks, ThemeHooks, MailHooks, and UserHooks. On the same ticket, 56 of 66 services were switched to autowire: true with FQCN aliases.

What caught my attention is that this team, with no connection to the first one, made the exact same two judgment calls:

  • No #[LegacyHook] shims. That attribute exists so contrib modules can keep supporting Drupal versions before 11.1. A first-party module that nothing else extends, already running on 11.1+, doesn't need it. Pure OOP, the same way core migrated itself.
  • #[Autowire(service: ...)] for the ambiguous stuff. Here the trouble wasn't custom service IDs but ExtensionList arguments, where several services share the same type. Same class of problem, same fix. The services that take non-object arguments like %site.path% stayed explicitly configured.

Two teams, two codebases, same month, same answers. That's what convinced me this isn't a one-off. It's the actual shape of the migration.

This project also did something the first one didn't. Once the .module file had no hooks left, it told Drupal not to scan it at all, with a container parameter in the module's services.yml (Drupal 11.2+):

parameters:
  platform_core.skip_procedural_hook_scan: true

One caveat: don't do this if your module implements hook_hook_info(), since there's no OOP counterpart for it.

And instead of deleting the .module file, it was left as a signpost. I love this pattern:

/**
 * @file
 * Primary module file for Platform Core.
 *
 * Deliberately empty of hook implementations. Every hook this module provides
 * lives in src/Hook/ as a class method carrying a #[Hook] attribute — the
 * Drupal 11.1+ object-oriented form:
 *
 * - src/Hook/FormHooks.php   form alters for person, login, password reset and
 *                            Appearance theme settings
 * - src/Hook/NodeHooks.php   person node insert / update / delete
 * - src/Hook/ThemeHooks.php  theme, theme registry, page attachments,
 *                            Appearance listing, themes installed
 * - src/Hook/MailHooks.php   transactional mail bodies and From headers
 * - src/Hook/UserHooks.php   staff logout presence sync
 *
 * No #[LegacyHook] shims are provided: platform_core is a first-party module
 * that nothing else extends, so there is no external caller relying on the old
 * procedural function names. Procedural hook scanning is skipped for this
 * module, so Drupal does not scan this file for hooks at all.
 */

The next developer who opens that file knows exactly where everything went. No archaeology needed.

The form hooks show the same "bodies unchanged, only DI changed" approach as the first project, plus one small thing that's an actual ergonomic win:

final class FormHooks {
 
  public function __construct(
    private readonly PersonFormAlter $personFormAlter,
    private readonly LoginFormAlter $loginFormAlter,
    private readonly ThemeSettingsFormAlter $themeSettingsFormAlter,
    private readonly RouteMatchInterface $routeMatch,
  ) {}
 
  #[Hook('form_node_person_form_alter')]
  #[Hook('form_node_person_edit_form_alter')]
  public function alterPersonForm(array &$form, FormStateInterface $form_state, string $form_id): void {
    $this->personFormAlter->alter($form, $form_state);
  }
 
  // ...
}

See the stacked attributes? One method, two hooks. Procedurally you'd write two functions, or two thin wrappers around a shared helper. This isn't just a refactor for the sake of it.

Verification here: 50 tests with 120 assertions (unit + kernel), phpcs taken from 287 errors and 112 warnings down to zero across the whole custom module with the code_standards CI job made blocking, and 21 out of 21 manual smoke checks across both tenant sites.

The checklist I'd hand to anyone starting this migration

  • It's mechanical until autowiring isn't. Core interfaces autowire for free. Custom service IDs and ambiguous types need #[Autowire(service: 'id')].
  • Run drush cr after every deploy of this change. The hook implementation cache is a new way to break a site, and a stale one gives you a 500.
  • Static callables only for #validate and #submit. [static::class, 'method'], never [$this, 'method'].
  • Leave install, uninstall, update_N, and schema hooks procedural. They belong in .install. And hook_requirements() is its own story: since 11.2 it's being replaced by runtime/update requirements hooks plus an install-time class, so check that separately instead of mixing it into this migration.
  • First-party module on 11.1+? Skip #[LegacyHook]. That shim is for contrib that still supports older cores.
  • Port the comments on purpose. Behavior-preserving isn't the same as knowledge-preserving.
  • Preserve the weird stuff. Unwired "hooks" stay unwired. Any intentional change gets flagged for sign-off.
  • Test discovery, not just behavior. A kernel test that proves your hooks are registered is cheap and catches silent wiring failures.
  • Build a hook-by-hook diff matrix. Same or Diff, for every single hook. It makes review honest.
  • Test in an environment that isn't yours. Minimal CI installs will find the services your local setup always happens to have.

One last note on scope: theme hooks are their own migration. OOP hooks for themes only arrived in 11.3, and on the Acquia site the ~87 theme functions are still waiting for their turn. I'll probably write about that one when it happens.

If you want to dig deeper, these are the references worth reading:

I hope this saves you at least one 500 error and one round of review. Happy coding!