Skip to content
System nominalUTC --:--:--Articles 085
MODULE 06 Lesson 16 / 18 35 MIN

Where it breaks, and shipping anyway

Hallucinated Functions and Invented Hooks

Why a green build cannot see an invented WordPress API, how to prove a function exists, and how to prove a hook actually fires on the site you are shipping to.

The most expensive defect an agent produces is not a crash. It is a name that looks right. A function with the wp_ prefix and a plausible verb reads as core, and it will pass every check you have that does not involve WordPress: it parses, it lints, it has no type error, and if you are building JavaScript it may not even reach the build error list. Two things then happen. Nothing calls the code, or the code throws at the moment a customer is looking at the page.

This lesson takes the two shapes apart, because they need different tests. A function is easy to prove or disprove, and the proof is one line in a real install. A hook is harder, because a hook that does not exist cannot be found by grepping your own code, and a hook that exists can still be the wrong hook for what you are doing. Both are solved by asking the running site questions, which is the only thing in this workflow that is not an opinion.

There are three failure classes, and only one is about spelling

Grouping these together as "hallucinations" hides the fact that they fail differently, and the test for each is different too.

ClassWhat the code looks likeThe test
A function that does not exist wp_sanitize_notice_message( $text ), where the only similar functions in core do something else function_exists() in a loaded WordPress, or the fatal the call produces when it runs
An export that does not exist import { PanelColorSettingsThatDoesNotExist } from '@wordpress/block-editor' Reading the built bundle, then loading the editor. The build passes either way.
A hook that never fires add_action( 'wp_before_notice_render', ... ) Watch whether the callback runs. There is no static check that can answer this.

The asymmetry is the point. The first class fails loudly as soon as the code executes, which means a single real run finds it. The second fails at runtime in the browser, which means a command-line test can miss it entirely. The third fails silently and always, which means it can survive every test you have, because a callback that never runs cannot break anything.

A green build is not a statement about WordPress

Here is a real build, run with @wordpress/scripts 36.1.0 on Node 24.11.1. The source file imports a component name that has never existed in any version of @wordpress/block-editor.

import { registerBlockType } from '@wordpress/blocks';
import { useBlockProps, PanelColorSettingsThatDoesNotExist } from '@wordpress/block-editor';

console.log( typeof PanelColorSettingsThatDoesNotExist );

registerBlockType( 'acme-notice/notice', {
	edit: () => useBlockProps(),
	save: () => null,
} );
$ npx wp-scripts build
asset notice/block.json 228 bytes [emitted] [from: src/notice/block.json] [copied]
asset notice/index.js 222 bytes [emitted] [minimized] (name: notice/index)
asset notice/index.asset.php 125 bytes [emitted] (name: notice/index)
orphan modules 84 bytes [orphan] 2 modules
./src/notice/index.js + 2 modules 522 bytes [not cacheable] [built] [code generated]
webpack 5.112.0 compiled successfully in 382 ms

Compiled successfully, exit code zero, for an import that does not exist. The reason is in the webpack plugin that wp-scripts configures for you. Its defaultRequestToExternal() maps every request beginning with @wordpress/ to a browser global by name, so @wordpress/api-fetch becomes wp.apiFetch and @wordpress/block-editor becomes wp.blockEditor. Externalising a module means webpack stops looking at it: it emits a reference to the global and emits the dependency handle, and it never asks whether the property you named is on that object. The built file says so plainly.

(()=>{"use strict";const o=window.wp.blocks,e=window.wp.blockEditor;console.log(typeof e.PanelColorSettingsThatDoesNotExist),(0,o.registerBlockType)("acme-notice/notice",{edit:()=>(0,e.useBlockProps)(),save:()=>null})})();
<?php return array(
	'dependencies' => array(
		'wp-block-editor',
		'wp-blocks'
	),
	'version' => '6a3b9acd9af4e5b70ad9',
);

The bundle contains the string PanelColorSettingsThatDoesNotExist exactly as you wrote it, as a property lookup on an object that WordPress provides at runtime. The asset file declares that this script needs wp-block-editor to be enqueued first, which is a statement about loading order and not about the contents of the package. Everything downstream of the build is a promise, and the promise is only kept if the API exists.

What actually happens is worse than an undefined variable, because the name is used as a component. Evaluating the bundle against a stand-in WordPress global and rendering the result with React 19.3.1 gives this.

typeof: undefined
Error: Element type is invalid: expected a string (for built-in components) or a class/function
(for composite components) but got: undefined. You likely forgot to export your component from
the file it's defined in, or you might have mixed up default and named imports.

That is the white screen in the editor, reported by React rather than by webpack, at the point where somebody opens the block. The build was never going to tell you, and neither is npm run lint:js, because a named import of a real module is valid JavaScript at every stage before the module is resolved.

function_exists() is a complete answer, and it is one line

For the first failure class there is no ambiguity and no excuse. WordPress is loaded, the function either exists or it does not, and the question takes one command. The version below was run against WordPress 7.0.7 with PHP 8.5.6.

wp eval '
foreach ( array( "wp_sanitize_notice_message", "wp_kses_post", "sanitize_hex_color" ) as $name ) {
	printf( "%-32s %s\n", $name, function_exists( $name ) ? "exists" : "MISSING" );
}'
wp_sanitize_notice_message       MISSING
wp_kses_post                     exists
sanitize_hex_color               exists

The uses of this are broader than checking the agent's output. Any change that calls a function from another plugin should be preceded by the same question about the function you are depending on, and the answer belongs in the guard clause rather than in a comment. The guard is what makes the dependency visible to the next reader, and it is also what turns a fatal into a branch you chose.

What function_exists() cannot do is answer for a hook, a filter, a constant, a class, a REST route or a block name. Each of those has its own probe, and reaching for the wrong one produces a confident answer about the wrong thing. A class is class_exists(), a constant is defined(), a registered post type is post_type_exists(), and a registered block is WP_Block_Type_Registry::is_registered(). If you cannot name the probe, you do not yet know what kind of thing you are depending on.

A hook that has listeners can still be a hook that never fires

Hooks are where confident agents do the most damage, because the API is a string and the string is unvalidated. Register a callback on a name that no version of WordPress has ever fired and nothing objects. The registration succeeds, the site runs, and the callback is never called.

<?php
// A plausible-looking hook that does not exist.
add_action( 'wp_before_notice_render', 'acme_studio_render_notice' );

// A real hook with a real core listener attached.
add_action( 'wp_footer', 'acme_studio_footer_note' );
$ wp eval 'foreach ( array( "wp_footer", "wp_before_notice_render" ) as $h ) { printf( "%-28s has_action=%-6s did_action=%d\n", $h, var_export( has_action( $h ), true ), did_action( $h ) ); }'
wp_footer                    has_action=true   did_action=0
wp_before_notice_render      has_action=false  did_action=0

Read that output carefully, because the obvious reading is wrong. has_action() returned true for wp_footer only because core and the active theme have callbacks attached to it. Then this happened.

$ wp eval 'add_action( "wp_before_notice_render", function () { echo "FIRED\n"; } ); var_export( has_action( "wp_before_notice_render" ) );'
true

The invented hook reports that it has a listener as soon as you attach one, which means has_action() answers "has anything registered here", not "does this hook exist". It is a useful function for a different question and a trap for this one. did_action() is the honest signal, because it counts how many times the hook has actually fired on this request, and no amount of registering will move it off zero.

The other check is the source itself. On the machine running the site, grep the installed core for the firing call.

grep -rn "do_action( 'wp_body_open'" wp-includes/ wp-admin/
grep -rn "do_action( 'wp_before_notice_render'" .
$ grep -rn "do_action( 'wp_body_open'" wp-includes/ wp-admin/
wp-includes/general-template.php:3240:	do_action( 'wp_body_open' );

$ grep -rn "do_action( 'wp_before_notice_render'" .
(no output)

One hit and one silence. Grep the installed version rather than a documentation site, because the question you are answering is about the WordPress on this server, and hook names change between releases. For a filter rather than an action the string to search is apply_filters(. If neither pattern finds your name anywhere under wp-includes, wp-admin or the active theme and plugins, the hook you invented is not a hook.

A hook can exist and still be the wrong one

Existence is the cheap half. The expensive half is timing: wp_footer fires after the template has rendered, wp_body_open fires as the body opens, and a filter that runs during init cannot see a post that has not been queried yet. When a callback runs but does nothing useful, check the frame that fired it in the stack trace and ask whether the data you need exists at that point. The hook's existence is settled; the phase is not.

Read the generated files before you read the source

The cheapest habit from this lesson is to read what the toolchain generated rather than trusting that it generated the right thing. In JavaScript that means the built bundle and the .asset.php file. In PHP it means the scaffolded file and the readme, which are generated by a template that has not been updated for the current era of WordPress and will happily hand you a plugin that declares a tested-up-to version from several releases ago. Neither file is a promise. Both are evidence, and evidence you can check in ten seconds.

The instruction to put in front of an agent follows from all of it. Do not claim an API exists. Run the probe, paste the command and its output, and if the probe was not possible, say which probe was unavailable instead of reasoning about the name. An agent that writes a plausible name and a plausible probe is more dangerous than one that admits it cannot check, because the second one leaves you a question and the first one leaves you a bug.

Lab 35 minutes

Build a plugin file that fails in all three ways, then prove each failure with the probe that fits it. Use a local install; the file is not meant to work.

  1. Write a PHP file with three registrations: a callback calling a made-up wp_ function, a callback on a made-up hook name, and a callback on a real hook that echoes a marker.
  2. Prove the made-up function does not exist, and prove the real one does, with function_exists().
  3. Prove the made-up hook has no firer using grep over the WordPress directory, and check has_action() and did_action() on both hook names. Explain the pair of results in one sentence.
  4. Fetch a page that fires the real hook, and confirm the marker appears. Then confirm it does not appear anywhere for the invented hook.
  5. In a JavaScript block source file, import a component from @wordpress/block-editor that does not exist. Build it, find the name in the bundle, and explain in writing why the build had nothing to say.
Show the reasoning, the expected output, and what each probe cannot prove

The three probes answer three different questions, and the reason this exercise is worth doing is that mixing them up produces false confidence. function_exists() answers a question about the loaded runtime. The grep answers a question about the source you have installed. did_action() answers a question about this request. None of them answers "will this run in production", which is why the lab ends with a fetch.

<?php
// Deliberately broken: three registrations, three different failure classes.
add_action( 'init', function () { echo wp_sanitize_notice_message( 'hello' ); } );
add_action( 'wp_before_notice_render', function () { echo 'invented hook fired'; } );
add_action( 'wp_footer', function () { echo '<!-- acme marker -->'; } );

The first callback will fatal the moment init runs, which is before the page renders, so on a real site it takes the whole request down. The second registration succeeds and produces nothing, forever. The third produces a marker in the footer, and that marker is the control: if you cannot make the real hook fire, your test is broken rather than your subject.

$ wp eval 'printf( "made-up: %s\nreal: %s\n", var_export( function_exists( "wp_sanitize_notice_message" ), true ), var_export( function_exists( "wp_kses_post" ), true ) );'
made-up: false
real: true

$ wp eval 'printf( "invented did_action=%d, real did_action=%d\n", did_action( "wp_before_notice_render" ), did_action( "wp_footer" ) );'
invented did_action=0, real did_action=0

Both hooks report zero firings on a WP-CLI request, and that is correct: WP-CLI does not render a page, so wp_footer never fires either. The comparison that works is the same pair read during a page request, which you can capture by hooking early enough that a fatal later in the footer cannot stop the logging.

<?php
// mu-plugin, for one request only.
add_action( 'wp_footer', function () {
	error_log( sprintf(
		'wp_footer=%d invented=%d',
		did_action( 'wp_footer' ),
		did_action( 'wp_before_notice_render' )
	) );
}, 0 );
[16-Oct-2026 22:59:53 UTC] DID_FOOTER wp_footer=1 invented=0

One and zero, on the same request, in the same log. The real hook reports that it is firing at the moment the callback runs; the invented hook reports that it has never fired. Running a probe in the wrong context and reading the zero as absence is the most common way this test is administered incorrectly, and the grep against the source is the check that needs no request at all.

The JavaScript half has no runtime probe unless you load the editor. The evidence is the bundle, and the honest conclusion is the one the build printed: compiled successfully, with the invented name sitting in the output as a property lookup. Because the name is used as a component, the render throws with React's invalid-element-type message, which is the only place the defect becomes visible. A browser test of the editor would catch it; a build, a linter and a unit test of your own code will not.

What none of these probes can tell you is whether a real, existing, correctly named API is the right one to use in this version. That is reading, and it is why the reading habit from Module 5 and the probes here are the same habit wearing different tools.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.