PHP 8 Attributes Example: Register WordPress Hooks

If you’re looking for a PHP 8 attributes example that is useful in WordPress, hook registration is a good one. Most plugin classes have a long constructor full of add_action() and add_filter() calls. The hook name sits in one place, the method sits somewhere else, and the priority and argument count are numbers you have to keep in sync with the method by hand. When you rename a method or add a parameter, the hook breaks quietly.

The snippet below puts the hook right on the method: #[Dpc_Hook( 'excerpt_length', priority: 20 )]. A registrar of about 30 lines reads those attributes with Reflection and calls add_filter() for you. It counts the method’s parameters to get accepted_args, and it supports several hooks on one method. Callbacks stay as normal [ $object, 'method' ] arrays, so remove_filter() still works. It needs PHP 8.1+ (readonly properties) and works on WordPress 6.8+.

The snippet

Put both parts in a small plugin, an mu-plugin or your theme’s functions.php. The file is self-contained, so you can also paste it into one Scripts Organizer PHP block. Part one is the attribute and the registrar. You write these once and reuse them in every plugin.

<?php
defined( 'ABSPATH' ) || exit;

#[Attribute( Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE )]
final class Dpc_Hook {
	public function __construct(
		public readonly string $name,
		public readonly int $priority = 10,
		public readonly ?int $accepted_args = null,
	) {}
}

final class Dpc_Hook_Registrar {
	/**
	 * Hook every public method of $instance that carries #[Dpc_Hook].
	 * Returns the number of hooks added.
	 */
	public static function register( object $instance ): int {
		$count = 0;
		$class = new ReflectionObject( $instance );

		foreach ( $class->getMethods( ReflectionMethod::IS_PUBLIC ) as $method ) {
			$attributes = $method->getAttributes( Dpc_Hook::class );
			if ( ! $attributes ) {
				continue;
			}

			$callback = $method->isStatic()
				? [ $class->getName(), $method->getName() ]
				: [ $instance, $method->getName() ];

			foreach ( $attributes as $attribute ) {
				$hook = $attribute->newInstance();

				add_filter(
					$hook->name,
					$callback,
					$hook->priority,
					$hook->accepted_args ?? max( 1, $method->getNumberOfParameters() ),
				);
				++$count;
			}
		}

		return $count;
	}
}

Part two is a real class that uses it. It shortens excerpts, prints a theme-color meta tag and closes comments and pings on attachment pages. Each method declares its own hook.

final class Dpc_Site_Tweaks {
	public function __construct(
		private readonly int $excerpt_words = 30,
		private readonly string $theme_color = '#0f172a',
	) {}

	#[Dpc_Hook( 'excerpt_length', priority: 20 )]
	public function excerpt_length( int $length ): int {
		return $this->excerpt_words;
	}

	#[Dpc_Hook( 'wp_head', priority: 1 )]
	public function theme_color(): void {
		printf( '<meta name="theme-color" content="%s">', esc_attr( $this->theme_color ) );
		echo PHP_EOL;
	}

	#[Dpc_Hook( 'comments_open', priority: 20 )]
	#[Dpc_Hook( 'pings_open', priority: 20 )]
	public function close_on_attachments( bool $open, int $post_id ): bool {
		return 'attachment' === get_post_type( $post_id ) ? false : $open;
	}
}

function dpc_site_tweaks(): Dpc_Site_Tweaks {
	static $instance = null;
	return $instance ??= new Dpc_Site_Tweaks( excerpt_words: 30 );
}

Dpc_Hook_Registrar::register( dpc_site_tweaks() );

How it works

  • #[Attribute( Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE )] turns Dpc_Hook into an attribute. It can only go on methods, and one method can carry it more than once. That is how close_on_attachments() runs on both comments_open and pings_open.
  • The attribute’s constructor uses promoted readonly properties, so a hook definition cannot change after it is read. Named arguments (priority: 20) let you skip the parameters you don’t need.
  • ReflectionObject::getMethods() lists the public methods. getAttributes( Dpc_Hook::class ) returns only our attribute, and newInstance() builds the Dpc_Hook object from it.
  • When you don’t set accepted_args, it comes from getNumberOfParameters(). Add a parameter to the method and WordPress passes the extra argument without you changing anything else.
  • Callbacks are normal [ $object, 'method' ] arrays, not closures. has_filter() and remove_filter() keep working, and Query Monitor shows readable names.
  • dpc_site_tweaks() keeps a single instance in a function static, so you can always get the object that was registered. No global variable is needed.

Customise it

To undo one hook somewhere else, for example on a single template, remove it through the same instance and pass the same priority:

remove_filter( 'excerpt_length', [ dpc_site_tweaks(), 'excerpt_length' ], 20 );
  • Use a new class for each feature (an admin class, a REST class, a WooCommerce class) and call Dpc_Hook_Registrar::register() once for each one.
  • If a hook passes more arguments than you want to declare, set accepted_args: 1 in the attribute.
  • Static methods work too. The registrar hooks them as [ ClassName, 'method' ].
  • For the full explanation of the tweaks used here, see changing the excerpt length and setting the mobile theme color.

Gotchas

  • Type hints are real. If another plugin puts null through comments_open before your callback runs, bool $open throws a TypeError. On filters you don’t control, use loose or nullable types (mixed, ?bool).
  • Only public methods are registered. WordPress can’t call a private method from outside the class, so the registrar skips them.
  • Reflection runs once per request for each registered object. For a handful of classes that is negligible. Don’t call register() inside a loop or on a hook that fires many times.
  • Register at load time, as shown, or on plugins_loaded. If you register on init, hooks that fire earlier, such as after_setup_theme, are already gone.
  • The #[...] syntax needs PHP 8.0 and readonly needs 8.1. On PHP 7 the attribute line is read as a comment, but readonly causes a fatal error. Set Requires PHP: 8.1 in your plugin header.

Prefer to keep snippets like this out of functions.php? Scripts Organizer lets you run PHP hooks from a code editor inside WordPress, with conditions.

Snippet poll. Should this snippet be in the Scripts Organizer library for one-click import? 10 votes put it there.

0 of 10 votes

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You’ll also get DPlugins product updates. No spam, and you can unsubscribe anytime.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You'll also get DPlugins product updates. No spam, and you can unsubscribe anytime.