WordPress core annotates the hook registry with a type. Open wp-includes/class-wp-hook.php in 7.1 and the property that holds every registered callback carries this:
/**
* Hook callbacks keyed by priority.
*
* @since 4.7.0
* @var array
* @phpstan-var array<int, array<string, Hook_Callback>>
*/
public $callbacks = array();Read that inner type: array<string, Hook_Callback>. Integer priority on the outside, string identifier on the inside. If you write code that walks the registry, that annotation is the closest thing to a specification you will find, and it tells you the keys are strings.
They are not always strings. On a stock WordPress 7.1 install, with no plugins loaded, some of those keys are integers. The annotation is not lying and core is not broken. The two facts simply live at different layers, and the gap between them is where a class of plugin bug lives that no amount of local testing will surface.
This is worth understanding as a general shape rather than as one trivia item, because the hook registry is only the most-visited example. Core exposes a great deal of its interior to anybody willing to reach for a global, and almost none of that interior is a promise.
Reproducing it in thirty seconds
Run this against any WordPress install. Plugins and themes are skipped deliberately, so nothing but core is involved.
wp eval '
add_filter( "demo", function ( $v ) { return $v; } );
add_filter( "demo", "trim" );
add_filter( "demo", array( "WP_Query", "foo" ) );
global $wp_filter;
foreach ( $wp_filter["demo"]->callbacks as $priority => $cbs ) {
foreach ( $cbs as $key => $cb ) {
printf( "key=%-22s type=%s\n", var_export( $key, true ), gettype( $key ) );
}
}
' --skip-plugins --skip-themesThe output on WordPress 7.1 with WP-CLI 2.12.0:
key=3192 type=integer
key='trim' type=string
key='WP_Query::foo' type=stringTwo of the three are strings. The closure is an integer. If your code iterates that array and does anything string-shaped with the key, it works for the first two callbacks and fails on the third, and which of the three it meets depends entirely on what other code happens to be loaded on the site.
Where the integer comes from
The identifier is produced by _wp_filter_build_unique_id(), and its signature is unambiguous about what it returns:
function _wp_filter_build_unique_id( $hook_name, $callback, $priority ): ?string {
if ( is_string( $callback ) ) {
return $callback;
}
if ( is_object( $callback ) ) {
return (string) spl_object_id( $callback );
}
// ...
if ( is_object( $callback[0] ) ) {
// Object class calling.
return ( (string) spl_object_id( $callback[0] ) ) . $callback[1];
} elseif ( is_string( $callback[0] ) ) {
// Static calling.
return $callback[0] . '::' . $callback[1];
}
return null;
}A return type of ?string, and an explicit (string) cast on the object branch. Core is being careful here. A closure is an object, so the identifier for a closure is the string form of its spl_object_id(), which is a small integer rendered as digits. "3192". Definitely a string, right up until it is used.
Then WP_Hook::add_filter() stores it:
$idx = _wp_filter_build_unique_id( $hook_name, $callback, $priority );
if ( null === $idx ) {
return;
}
$this->callbacks[ $priority ][ $idx ] = array(
'function' => $callback,
'accepted_args' => (int) $accepted_args,
);That last line is where the type is lost, and it is lost to PHP rather than to WordPress. PHP converts array keys that look like canonical decimal integers into actual integers, always, with no way to opt out. It is not a coercion you can disable with strict_types and it is not affected by how carefully the value was cast on the way in.
php -r '
$a = [];
$a[ (string) 42 ] = "cb";
foreach ( $a as $k => $v ) { echo gettype( $k ), PHP_EOL; } // integer
$b = [];
$b["MyPlugin::init"] = 1;
foreach ( $b as $k => $v ) { echo gettype( $k ), PHP_EOL; } // string
'So the function keeps its promise and the container breaks it. Nobody wrote a bug. The annotation describes the intent of the design, the runtime describes what the language does with it, and a developer reading only the first one writes code that fails on the second.
What it costs when it lands
The obvious thing to do with a registry key is compare it to a string, and the obvious way to do that is substr(). Under PHP’s default coercive typing that is harmless. The integer converts and the call succeeds:
# Coercive mode
php -r 'var_dump( substr( 42, 0, 3 ) );'
string(2) "42"
# Strict mode
php -r 'declare(strict_types=1); substr( 42, 0, 3 );'
PHP Fatal error: Uncaught TypeError: substr(): Argument #1 ($string)
must be of type string, int givenWhich means the severity of this particular mistake is decided by a declaration at the top of the file rather than by the line that makes it. A file without strict_types silently coerces and nobody ever finds out. The same code in a file that opts into strict typing throws an uncatchable fatal.
That is an uncomfortable incentive if you read it the wrong way, so it is worth being clear: strict_types is not the problem. It is the thing that told you about a wrong assumption instead of hiding it. The problem is the assumption, and the assumption had been in the code for as long as it took for a site to register a closure on the hook being walked.
The reason this shape of bug reaches production intact is the same reason described in the upgrade path nobody tests. Your development site has your plugin and a theme. A customer site has thirty plugins, several of which register anonymous functions on hooks yours also touches. The condition that produces the integer key is not a rare edge case in the wild. It is a rare edge case in the environment where you look.
Four more places the same shape appears
The hook registry is the one people meet first. It is not unusual.
The admin menu globals are positional tuples
Reordering or removing admin menu entries by editing $menu and $submenu directly is common enough to appear in most agency toolkits. Here is what add_menu_page() actually appends:
$new_menu = array( $menu_title, $capability, $menu_slug, $page_title,
'menu-top ' . $icon_class . $hookname, $hookname, $icon_url );
$menu[] = $new_menu;Seven elements, no keys, no names. To find the slug you index position 2, and the only way you know it is position 2 is that you counted. Nothing in that array says what it is. A future core change that inserts a field, or reorders one, breaks every plugin that counted, and there is no deprecation path available for an unnamed positional tuple because there is nothing to deprecate.
The submenu array is worse in a way that is easy to miss. Registration normally appends four elements:
$new_sub_menu = array( $menu_title, $capability, $menu_slug, $page_title );
// ...
$submenu[ $parent_slug ][] = $new_sub_menu;But when core has to synthesise the first child of a parent menu, it does this instead:
$submenu[ $parent_slug ][] = array_slice( $parent_menu, 0, 4 );A four-element slice of the seven-element top-level row. The first four fields of the two shapes happen to align, which is why this works and why it is invisible. It also means the contents of $submenu come from two different constructions, and any assumption you make about one of them is only accidentally true of the other.
The underscore prefix is a boundary marker
A quick count against WordPress 7.1 source:
# Functions whose name starts with an underscore
grep -rhoE '^function _[a-z0-9_]+\(' wp-includes wp-admin | sort -u | wc -l
416
# Docblocks explicitly marked private
grep -rho '@access private' wp-includes wp-admin | wc -l
676Four hundred and sixteen functions announce in their own name that they are not for you. They are callable, they work, they have no runtime guard, and calling one is entirely painless until the release where its signature changes without a deprecation notice, because a deprecation notice is a courtesy owed to a public API.
_wp_filter_build_unique_id() is itself on that list, which is worth sitting with for a second. The function that generates the keys everybody wants to read is marked private by the oldest naming convention in the project.
The two query globals are not interchangeable
$wp_query and $wp_the_query hold the same object most of the time, which is exactly what makes the difference expensive to learn. $wp_the_query is the main query. $wp_query is whatever query is currently being treated as the main one, which changes during a query_posts() call and during some template contexts.
Code written against $wp_query and tested on a normal archive page behaves correctly, then reads the wrong object on the one template where something has swapped it. The supported route is is_main_query() on the query object you were handed, not a global comparison you assembled yourself.
Anything you learned from var_dump
This is the general case and it deserves naming directly. If the way you discovered a structure was to dump it and look, you have observed one instance of it on one site at one moment. You have not read a specification, because if a specification existed you would have read that instead.
Everything above falls out of this rule. The registry keys were strings on the machine where somebody looked. The menu array had seven elements in the order somebody counted. Both observations were accurate and neither was a promise.
A four-question test
Before you depend on something core exposes, run it through these. Any single no is enough to treat it as an internal.
- Are you reaching it through a documented function, or reading a structure directly?
has_filter()is an API.$wp_filter[ $hook ]->callbacksis a data structure that an API happens to be built on. - Does the name start with an underscore, or does the docblock say
@access private? Both are the project telling you in advance, in writing, that it reserves the right to change this. - Did you learn its shape from documentation, or from dumping it? If the developer reference has no page for it, there is nothing for a core contributor to consult before changing it.
- Would core consider a change to it a breaking change? This is the honest one. Renaming
get_posts()‘s third parameter is a backwards compatibility break that would be discussed for months. Changing what type ends up as an array key inside a private data structure is a Tuesday.
The fourth question is the one that actually decides it, and it is the one people skip because it requires imagining a room you are not in.
What to use instead
WordPress 7.1 core contains roughly 2,900 do_action() and apply_filters() call sites. That number is the actual extension surface, and it is the part of core that carries a real commitment: hooks get deprecation shims, they appear in the developer reference, and removing one is a decision rather than a refactor.
grep -rhoE "(do_action|apply_filters)(_ref_array|_deprecated)?\( *'[a-z0-9_/-]+'" \
wp-includes wp-admin | wc -l
2896For the specific things people walk the registry to do, there is a supported route for each.
Checking whether a callback is registered
// Returns the priority, or false. Note that priority 0 is falsy,
// so compare against false explicitly.
$priority = has_filter( 'the_content', 'wpautop' );
if ( false !== $priority ) {
// registered at $priority
}
// Is anything at all hooked here?
if ( has_filter( 'the_content' ) ) {
// ...
}Removing a callback you registered
remove_filter() needs the same callback and the same priority that were used to add it. Its priority argument defaults to 10, which is the single most common reason a removal silently does nothing:
add_filter( 'the_content', array( $this, 'render' ), 20 );
remove_filter( 'the_content', array( $this, 'render' ) ); // no-op
remove_filter( 'the_content', array( $this, 'render' ), 20 ); // worksFor an object method this is straightforward as long as you have the same instance. If your plugin registers callbacks from a singleton or a container, ask the container for the instance rather than constructing a new one, because a fresh object has a different spl_object_id() and therefore a different identifier.
Removing a closure
This is the case that sends people into the registry, because a closure cannot be reconstructed and therefore cannot be passed to remove_filter() after the fact. Walking $wp_filter looking for it is the workaround, and it is the workaround that produces integer keys, type errors and the rest of this article.
The fix is to stop needing it. Keep the reference at registration time:
class My_Feature {
/** @var callable|null */
private $handler = null;
public function attach(): void {
$this->handler = function ( $value ) {
return $this->transform( $value );
};
add_filter( 'the_content', $this->handler, 20 );
}
public function detach(): void {
if ( null !== $this->handler ) {
remove_filter( 'the_content', $this->handler, 20 );
$this->handler = null;
}
}
}Two lines more at registration, and the removal is exact rather than a search. It also survives another plugin registering a closure on the same hook, which a name-matching search does not.
If you need to clear a hook wholesale rather than remove one callback, WP_Hook has a public method for it, and it is a documented part of the class rather than a structure you are reading around:
remove_all_filters( 'the_content' ); // every priority
remove_all_filters( 'the_content', 20 ); // just priority 20Use it with care in a plugin. Clearing a hook removes other people’s callbacks along with your own, and it is a common cause of the kind of cross-plugin failure that is nearly impossible to attribute from the outside.
Changing the admin menu
add_action( 'admin_menu', function () {
remove_menu_page( 'edit-comments.php' );
remove_submenu_page( 'options-general.php', 'options-writing.php' );
}, 999 );Both take slugs rather than positions, both are documented, and neither cares how many fields a menu row happens to have this release. The high priority matters: menu entries registered by other plugins are not all in place at the default priority.
When you genuinely have no choice
Sometimes there is no hook. A third-party plugin registers a closure and gives you no way to reach it, or you need to inspect state that core exposes nowhere else. Reaching into an internal is then a legitimate engineering decision rather than a mistake, and the difference between the two is entirely in how you write it.
- Cast at the boundary, every time. The moment a value crosses out of an internal structure and into your code, give it the type your code expects.
substr( (string) $key... )is one character of defensiveness against an entire class of failure. - Check the shape before you read it.
isset()on the index you are about to use,is_array()before you iterate, a count check before you rely on position 4 existing. A structure you do not control can be a different structure next release. - Put it in one function with a name that says so.
find_closure_key_in_registry()in one file beats the same three lines inlined in four places. When it eventually breaks, you want one site of repair and one thing to search for. - Test it against a realistic registry. Register a closure, a string callback and a static method in your fixture, exactly as the reproduction above does. The bug only exists when all three are present, so a fixture containing one will pass forever.
- Write down why, in the file. A one-line comment naming what you needed and what was missing. It tells the next person this was considered rather than casual, and it is what you will search for when core changes.
The same instinct is what makes a free and Pro plugin pair survivable across releases, which is the subject of coupling free and Pro plugins without copying code. The seam between two things you control deserves a deliberate contract for the same reason the seam with core does, and the difference is that with core you are only ever on one side of it.
The part that is not about types at all
There is a version of this argument that ends with a rule about casting, and that version is too small. Casting fixes one line. The reason the line existed is that core exposes an enormous interior with no fence around it, and PHP makes reaching into it as easy as reaching for a documented function. There is no compiler telling you which side of the line you are standing on, and the naming convention that would tell you is a leading underscore that PHP itself does not enforce.
That is not going to change, and asking core to lock it down would break most of the plugin ecosystem overnight. The openness is load-bearing. WordPress runs a large share of the web partly because you can always get at the thing you need, including the things nobody planned for you to need.
What that leaves is a discipline rather than a guard rail. Know which side you are on. Write it down when you cross. Cast at the boundary. Everything else in this article is an example of what happens when those three go unobserved, and none of the examples were written by careless people.
The related version of this problem, where a documented API changes underneath you rather than an undocumented one, is worth reading next: what actually breaks when WordPress 7.1 ships jQuery UI 1.14.2 is the same audit exercise against a dependency that did announce its changes.
Summary
- Core annotates the hook registry’s inner keys as strings. PHP converts numeric-string array keys to integers, so a closure’s key is an integer. Both statements are correct.
- Reproduce it with
wp evaland--skip-plugins. No plugins required, threeadd_filter()calls. - Under coercive typing the mistake is silent. Under
declare(strict_types=1)it is a fatal. The declaration decides the severity, not the line. - The same shape covers
$menuand$submenupositional tuples, 416 underscore-prefixed functions, 676@access privatedocblocks, and the difference between$wp_queryand$wp_the_query. - The test: documented function or raw structure, underscore or not, documentation or
var_dump, and whether core would call a change to it a break. - Use
has_filter(),remove_filter()with the correct priority,remove_all_filters(),remove_menu_page(). To remove a closure, keep the reference at registration rather than searching for it later. - When you must reach inside anyway: cast at the boundary, check the shape, isolate it in one named function, test against a realistic fixture, and leave a comment saying why.





No comments yet