Working on a site someone else built
Debugging a Fatal With the Agent in the Loop
A real fatal from a real install, why the front end can return HTTP 200 while the page is broken, and the reproduce-narrow-verify loop an agent has to follow.
The client's message is that the site "looks broken at the bottom". You open the page and it
ends in the middle of the footer, with no closing tags. The site is up, the homepage returns
HTTP 200, and wp plugin list runs without a complaint. Nothing in the admin says
anything is wrong, because whatever is wrong happens after the response has started and returns
no status code of its own.
This is the most common shape of a WordPress fatal, and it is the reason this lesson runs
against a real install rather than describing one. Everything below was produced on WordPress
7.0.7 with PHP 8.5.6, in a child theme of Twenty Twenty-Five with a dead plugin dependency. The
commands and the outputs are copied from that run, with the site path shortened to
/srv/http/acme.test.
The front end returned 200 while the page was broken
Here is what a visitor received. The response ends after the footer's opening markup, with no
closing </html>, and the status line says the request succeeded.
$ curl -s -o page.html -w "HTTP %{http_code}\n" https://acme.test/
HTTP 200
$ tail -c 120 page.html
<a href="https://wordpress.org" rel="nofollow">WordPress</a> </p>
</div>
</div>
</footer>
</div>
$ grep -c "</html>" page.html
0
The 200 is not a lie from the server. PHP sent the status line and the first part of the body when the template started rendering, and headers cannot be changed after that. The fatal happened in the footer, several hundred milliseconds later, so there was no status code left to send. Your monitoring will report the site as healthy, and every uptime check that only looks at status codes will agree.
The admin tells a different story, which is why the first move on an unexplained break is
always wp-admin. There, WordPress can still influence the response.
$ curl -s -b cookies.txt -o admin.html -w "HTTP %{http_code}\n" https://acme.test/wp-admin/index.php
HTTP 500
$ sed -n '/<body/,$p' admin.html | sed 's/<[^>]*>//g' | tr -s ' \n' ' '
WordPress › Error There has been a critical error on this website. Please check your site
admin email inbox for instructions. If you continue to have problems, please try the support
forums . Learn more about troubleshooting WordPress.
Two surfaces, two different answers to the same question, and neither of them names the file. The email that WordPress sent to the admin address carries a recovery-mode link, which is the route a site owner would normally take and not the route you want, because recovery mode is for getting back into the admin, not for finding the bug.
The log has the line, and the line is the whole diagnosis
With WP_DEBUG and WP_DEBUG_LOG on, the fatal is written to
wp-content/debug.log. This is the output from the run, and it is the artefact you
hand to an agent.
[16-Oct-2026 09:14:02 UTC] PHP Fatal error: Uncaught Error: Call to a member function get() on null in /srv/http/acme.test/wp-content/themes/acme-studio/functions.php:15
Stack trace:
#0 /srv/http/acme.test/wp-includes/class-wp-hook.php(341): acme_studio_footer_note('')
#1 /srv/http/acme.test/wp-includes/class-wp-hook.php(365): WP_Hook->apply_filters(NULL, Array)
#2 /srv/http/acme.test/wp-includes/plugin.php(522): WP_Hook->do_action(Array)
#3 /srv/http/acme.test/wp-includes/general-template.php(3224): do_action('wp_footer')
#4 /srv/http/acme.test/wp-includes/template-canvas.php(25): wp_footer()
#5 /srv/http/acme.test/wp-includes/template-loader.php(132): include('/srv/http/ac...')
#6 /srv/http/acme.test/wp-blog-header.php(19): require_once('/srv/http/ac...')
#7 /srv/http/acme.test/index.php(17): require('/srv/http/ac...')
#8 {main}
thrown in /srv/http/acme.test/wp-content/themes/acme-studio/functions.php on line 15
Read it from the bottom. Frame #0 is the crash: acme_studio_footer_note()
was called and something inside it was null. Frame #3 is the only frame you did not
write: do_action('wp_footer'). Everything else is WordPress's plumbing. The message
says a method was called on null rather than that a method does not exist, which matters: the
object arrived and was empty, so the question is where it came from.
Line 15 of the theme file is the foreach that calls
$brand->get( 'palette' ). Four lines above it,
acme_studio_brand_config() checks function_exists() and returns
null when the agency toolkit plugin is not active. The guard is working exactly as
written, and the caller assumed the only two possible answers were an object and an error.
Reproduce it without a browser, then narrow it one frame at a time
The loop that finds this class of bug has four steps, and an agent can run all four if you allow it to run commands. Reproduce, narrow, hypothesise, verify. The first step is the one that turns a report into a bug you can work on.
cd /srv/http/acme.test
printf '<?php\nacme_studio_footer_note();\n' > /tmp/repro.php
wp eval-file /tmp/repro.php
$ wp eval-file /tmp/repro.php
Fatal error: Uncaught Error: Call to a member function get() on null in
/srv/http/acme.test/wp-content/themes/acme-studio/functions.php:15
Stack trace:
#0 phar:///opt/homebrew/Cellar/wp-cli/2.12.0/bin/wp/vendor/wp-cli/eval-command/src/EvalFile_Command.php(85) : eval()'d code(2): acme_studio_footer_note()
#1 phar:///opt/homebrew/Cellar/wp-cli/2.12.0/bin/wp/vendor/wp-cli/eval-command/src/EvalFile_Command.php(85): eval()
#2 phar:///opt/homebrew/Cellar/wp-cli/2.12.0/bin/wp/vendor/wp-cli/wp-cli/php/WP_CLI/Runner.php(470): EvalFile_Command->__invoke(Array, Array)
...
#15 /opt/homebrew/Cellar/wp-cli/2.12.0/bin/wp(4): include('phar:///opt/hom...')
#16 {main}
thrown in /srv/http/acme.test/wp-content/themes/acme-studio/functions.php on line 15
The stack is noisy because every frame between the crash and {main} belongs to
WP-CLI's own dispatcher, and the middle of it is elided here rather than edited to look tidy. The
two lines that matter are the message, which names the file and the line, and frame
#0, which names the callback. Everything under it is the tool you called the code
with, not the site.
One command, a stack trace that names the theme file and the line, and no browser involved. That is the reproduce step, and it is worth more than any amount of reading, because it proves the callback is callable and proves the failure is inside it. The narrowing steps then ask the site questions instead of reading the file.
wp eval 'var_export( acme_studio_brand_config() );'
wp eval 'var_export( function_exists( "acme_brand_get_config" ) );'
wp plugin list --status=active
$ wp eval 'var_export( acme_studio_brand_config() );'
NULL
$ wp eval 'var_export( function_exists( "acme_brand_get_config" ) );'
false
$ wp plugin list --status=active
name status update version
(no output)
Three commands, three facts that eliminate every other explanation. The config function is reachable and returns null. The function it delegates to does not exist. No plugin is active, so the missing function is missing because the plugin that defines it was deactivated, not because someone misspelled it. The bug is a deactivated dependency, and the theme's mistake is that its guard returns a value the caller then dereferences.
WP-CLI succeeding is not evidence the site works
wp theme list, wp plugin list and wp option get all ran
cleanly on a site whose front page was cut in half. WP-CLI loads WordPress and runs your
command; it does not render a page, so hooks that only fire during a front-end request never
run. Give the agent this distinction explicitly, or it will report that the site is fine on the
evidence of a command that cannot see the bug.
Give the agent a bounded diagnosis, not an open-ended fix
An agent with the log line and the four commands above will usually find this bug. It will also
usually try to fix it in ways you do not want: rewriting the guard so the callback returns early,
editing the parent theme's template, or adding an is_object() check and calling the
job done. Each of those makes the fatal go away, and only one of them leaves a site that behaves
correctly when the plugin comes back.
The instruction that keeps the diagnosis honest is to require the answer before the fix. Ask for three things: which callback crashed, which value was null, and which file defines the function that should have supplied it. All three are checkable, and a wrong answer is usually visible in the first sentence. Then ask for the smallest change that fixes the cause rather than the symptom, and require the verification command in the same message as the change.
For this bug, the cause is that the theme called a plugin function without checking that the plugin was there, while every other call site in the theme does check. The fix belongs in the child theme that is active, in the function that returns the config, and it should produce a value the footer can render instead of a fatal or an empty footer. Deleting the footer output would be the symptom fix, and it would remove the client's branding for a plugin that is coming back next week.
Build the bug, capture its three surfaces, then debug it with the agent. Use a local or staging WordPress you can break.
- Create a child theme whose
functions.phpregisters a callback onwp_footer. Have that callback call a helper that returnsnullwhen a function from a second plugin is absent, and then treat the return value as an object. Activate the child theme without activating the second plugin. - Capture the three surfaces: the front-end response with its status code and its last
two hundred characters, the
wp-adminstatus code, and thedebug.logentry. - Reproduce the fatal with a single
wpcommand, then answer the three questions from the log alone: which callback, which value was null, which file should have defined the missing function. - Give the agent the log entry and the three questions. Do not give it the source file. Record whether its answer is right, and what it proposed to change.
- Apply a fix that keeps the footer rendering when the dependency is absent, then prove
it with the original front-end fetch and a fresh check of
debug.log.
Show the diagnosis, two acceptable fixes, and one that is not
The three answers are in the log. The callback is acme_studio_footer_note(),
which frame #0 names and frame #3 places on wp_footer.
The null value is whatever acme_studio_brand_config() returned. The missing
definition is acme_brand_get_config(), and the theme's own guard names it, which
is the clue that the theme expects a plugin to provide it.
The first acceptable fix keeps the guard and moves the decision to where a value is required. Give the theme a palette function whose contract is always an array, so the caller cannot receive null no matter what the plugin does or does not provide.
<?php
function acme_studio_brand_palette_default() {
return array( 'acme-ink' => '#111111' );
}
function acme_studio_brand_palette() {
if ( ! function_exists( 'acme_brand_get_config' ) ) {
return acme_studio_brand_palette_default();
}
$config = acme_brand_get_config();
$palette = is_object( $config ) && method_exists( $config, 'get' )
? $config->get( 'palette' )
: acme_studio_brand_palette_default();
$clean = array();
foreach ( (array) $palette as $slug => $hex ) {
$hex = sanitize_hex_color( (string) $hex );
if ( $hex ) {
$clean[ sanitize_key( $slug ) ] = strtoupper( ltrim( $hex, '#' ) );
}
}
return $clean ? $clean : acme_studio_brand_palette_default();
}
Every function in that block exists in WordPress, and running it on the install from this lesson returns the fallback palette while the plugin is inactive, because the guard takes the first branch.
$ wp eval-file /tmp/palette-fallback.php
array (
'acme-ink' => '#111111',
)
The second acceptable fix makes the caller treat absence as a state it handles, which is the right shape when there is no sensible default and the section should simply not render.
<?php
function acme_studio_footer_note() {
$brand = acme_studio_brand_config();
if ( ! is_object( $brand ) || ! method_exists( $brand, 'get' ) ) {
return;
}
// ... render the swatches.
}
The two choices differ in what the visitor sees. The first prints a house default, which is wrong branding but a complete page. The second prints nothing, which is correct because the plugin is gone and wrong the moment someone expects a footer. Pick based on whether the footer is decoration or content, and write the reason in the commit message, because the next developer will otherwise read the guard as paranoia.
The fix that is not acceptable is guarding on the return value only, with
if ( null === $brand ) { return; }. It removes the fatal and silently deletes the
client's entire footer while the plugin is inactive, which is exactly the failure mode that
makes a fatal preferable to a bug. A fatal is loud, located and reported. A missing footer is a
support ticket three weeks later saying the site looks different.
One more thing the agent should have caught and usually does not: the log line names a child theme file, so the fix goes in the child theme. If the same function existed in the parent theme, an agent asked to fix the callback would edit the parent, and the fix would survive exactly one parent update.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.