Skip to main content
← All articles
drupal

hook_ is dying (slowly) and I started rewriting my .module files anyway

hook_ is dying (slowly) and I started rewriting my .module files anyway

The SEO module that runs this blog is one file, blog_seo.module, and it is pure old-school Drupal: a handful of procedural functions hanging off hook_page_attachments() to inject JSON-LD and Open Graph tags. It works fine. It has worked fine since Drupal 6 taught me to write functions named after the module they live in. And every time I open it now, the attribute-hooks part of my brain winces a little.

So I converted it. Then I went looking for how much of a hurry I was actually in, and the honest answer surprised me.

First, the buzzkill: your .module files are not on fire

If you've seen "procedural hooks are deprecated" going around, it's wrong, or at least premature. As of mid-2026 the plan to deprecate them (issue #3481555) is still sitting in "Needs review." Nothing has been formally deprecated. And because core now schedules disruptive removals to preserve backward compatibility with Drupal 10.5, the earliest procedural hooks actually go away is Drupal 13 — currently targeted for 2028. Drupal 12 lands the week of 7 December 2026 and won't remove them.

So this is not a migration you have to run this quarter. Anyone telling you otherwise is selling urgency. What's real is the direction of travel: core is steadily converting its own hooks, the tooling assumes OOP, and new code has no excuse.

Why I bothered anyway

The #[Hook] attribute has been the stable, recommended way to implement module hooks since Drupal 11.1 (change record), and themes got it in 11.3. The appeal isn't compliance. It's that a hook stops being a magic global function and becomes an ordinary method on an ordinary class, with real constructor injection instead of \Drupal::service() calls scattered through a procedural body. My blog_seo code was already reaching for the config factory the ugly way. Attributes let me inject it properly and stop apologising.

The conversion, concretely

Here's the before, trimmed to the shape of it:

<?php

/**
 * Implements hook_page_attachments().
 */
function blog_seo_page_attachments(array &$attachments): void {
  $config = \Drupal::config('blog_seo.settings');
  if ($config->get('enable_jsonld')) {
    $attachments['#attached']['html_head'][] = [/* JSON-LD build */];
  }
}

And the after. It moves into src/Hook/SeoHooks.php, namespace Drupal\blog_seo\Hook:

<?php

declare(strict_types=1);

namespace Drupal\blog_seo\Hook;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Hook\Attribute\Hook;

final class SeoHooks {

  public function __construct(
    private readonly ConfigFactoryInterface $configFactory,
  ) {}

  #[Hook('page_attachments')]
  public function pageAttachments(array &$attachments): void {
    $config = $this->configFactory->get('blog_seo.settings');
    if ($config->get('enable_jsonld')) {
      $attachments['#attached']['html_head'][] = [/* JSON-LD build */];
    }
  }

}

That's the whole trick. No blog_seo.services.yml entry — since 11.1 the class is auto-discovered and its constructor autowired. One method can carry several #[Hook(...)] attributes if it implements more than one hook. Once the last function was out, the .module file had nothing left in it, so it went in the bin. That part felt good.

The gotcha that will actually bite you: double execution

If your module has to keep supporting Drupal 10 (which has no attribute hooks), you can ship both — but you must tag the procedural version with #[LegacyHook], or on modern Drupal the hook fires twice: once for the attribute, once for the leftover function.

<?php

use Drupal\Core\Hook\Attribute\LegacyHook;
use Drupal\blog_seo\Hook\SeoHooks;

/**
 * Implements hook_page_attachments().
 */
#[LegacyHook]
function blog_seo_page_attachments(array &$attachments): void {
  \Drupal::service(SeoHooks::class)->pageAttachments($attachments);
}

On old Drupal the attribute is ignored and the function runs normally; on 11.x the function is skipped and the method runs. My blog only ever runs on 11, so I skipped the shim entirely and deleted the function. If you maintain contrib, you don't get to be that lazy.

What you can't convert (don't try)

  • Install and update lifecycle: hook_update_N(), hook_install(), hook_schema() stay procedural in .install. Leave them alone.
  • Conditional definitions: you can't wrap a #[Hook] method in a runtime if. Attribute hooks are discovered statically.
  • Ordering tricks: if you used hook_module_implements_alter() to reorder, that's superseded by order: on the attribute, plus #[RemoveHook] and #[ReorderHook] to reach into other modules. Cleaner, honestly.

Let Rector do the boring 80%

palantirnet/drupal-rector has a HookConvertRector rule that writes the src/Hook/ class for you and leaves a legacy wrapper behind. One catch worth knowing: run it as its own separate pass, not bundled with the normal deprecation sets — Rector can't feed the file it just wrote back through the other rules in a single run. It gets you most of the way; the dependency-injection cleanup and the "is this method name embarrassing" pass are still yours.

So: do you need to do this?

No. Not this year, probably not next. But write every new hook as a #[Hook] class starting now, and when you're already elbow-deep in an old module, let Rector convert the rest while you're there. Keep your .install procedural, don't panic about a 2028 deadline, and enjoy deleting a .module file that has been following you around since Drupal 6.

Links

BM
Blue Moose
The moose behind Blue Moose. Full-stack PHP developer — Drupal by day, Symfony by night, tests always.