The difference between a junior and a senior WordPress developer isn’t the tools. It’s what happens in the first ninety seconds after someone says “the site is broken.”
A junior opens the dashboard, starts deactivating plugins one at a time, and hopes. A senior asks three questions: “What exactly is broken? When did it start? What changed?” Then they form a hypothesis, test it in thirty seconds, and either confirm or move on. Bugs that eat a junior’s afternoon get solved over a senior’s coffee.
This post isn’t a tool list. Tool lists already exist and they’re all the same. This is how to actually debug WordPress — the mental model, the workflow, the methodology — with the tools as the supporting cast.
The Mental Model: Debugging Is Thinking, Not Clicking
Before you touch anything, internalise this: debugging is scientific method, applied to code.
You form a hypothesis about what’s wrong. You design an experiment that tests the hypothesis cheaply. You run the experiment. The result either confirms the hypothesis or rules it out. You move to the next hypothesis. You repeat until you’ve found the bug.
The speed at which you debug is determined almost entirely by how cheaply you can test each hypothesis. A junior’s debugging cycle is ten minutes because they’re deactivating plugins, waiting for the site to reload, clicking around to check if the bug is still there. A senior’s cycle is ten seconds because they’ve got WP-CLI open in one terminal, tail -f debug.log in another, and a test case they can trigger with one command.
Everything we’re about to cover is in service of that one goal: shortening the hypothesis-to-result cycle until it’s faster than thinking.
Step 1: Reproduce Reliably (This Is Harder Than You Think)
You cannot debug what you cannot reproduce. Step zero is getting the bug to happen on demand.
The mistake most developers make here is accepting a vague reproduction case. “It’s slow sometimes.” “The form doesn’t always submit.” “Users are seeing errors.” These are not reproductions. These are symptoms.
Turn every vague report into a precise test case. What URL? What user role? What browser? What data? What time of day? What action specifically? “When I submit a product with more than 20 variations, logged in as a shop manager, on the variable-product edit screen” is a reproduction. “Product saves are broken” is not.
Once you have a precise reproduction, the very next thing you do is write it down. Literally. In a text file. The exact steps:
REPRO: Variation save bug
1. Log in as shop_manager role
2. Go to /wp-admin/post.php?post=1234&action=edit
3. Click Variations tab
4. Add a 21st variation
5. Click "Save variations"
6. Expected: 200 OK, variations saved
7. Actual: 500 error, nothing saved, error_log shows "Allowed memory size exhausted"
You’ll read this file forty times during the debugging session. Having it written down means you don’t lose track of what you’re actually solving. It’s also the specification for your eventual fix — when you think you’ve fixed the bug, run these exact steps, see the actual match expected, and you’re done.
If you cannot reproduce on your local or staging environment, the bug has environmental context. That context is now part of the problem: you need to make your reproduction environment match production in whatever dimension matters. Memory limit? Database size? PHP version? Concurrent users? Object cache? Find it.
Step 2: Isolate the Scope
Once you can reproduce reliably, the next job is narrowing the suspect space. “Something in 60 plugins is causing this” is not a scope. “Something in these 3 plugins is causing this” is progress.
The standard advice is “deactivate all plugins, then reactivate one at a time.” This is correct in principle and comically slow in practice. With 40 plugins, you’re doing 40 reactivation cycles, each of which requires clicking “Plugins”, finding the plugin, clicking Activate, navigating back to your reproduction URL, triggering the bug. That’s 5+ minutes per cycle. Three hours to narrow down.
Do it faster with WP-CLI and binary search:
# List all active plugins
wp plugin list --status=active --field=name > /tmp/all-plugins.txt
# Deactivate the first half
head -20 /tmp/all-plugins.txt | xargs wp plugin deactivate
# Test your reproduction
# Bug gone? The problem is in plugins 1-20. Keep bisecting.
# Bug still there? The problem is in plugins 21-40.
# Reactivate and deactivate the other half
wp plugin activate $(head -20 /tmp/all-plugins.txt)
tail -20 /tmp/all-plugins.txt | xargs wp plugin deactivate
Binary search gets you from 40 plugins to 1 plugin in 6 cycles, not 40. That’s 30 minutes instead of 3 hours.
Better still: instead of deactivating everything and looking for the bug to disappear, enable them and look for the bug to appear. The state where the bug is present carries more information than the state where it’s absent, because you immediately see the conflict.
And sometimes — and this is the senior move — you skip plugin isolation entirely because you already have a better hypothesis. If the bug started after updating WooCommerce, the first hypothesis is “WooCommerce changed something.” Test that directly:
# Roll WooCommerce back to the version before the update
wp plugin install https://downloads.wordpress.org/plugin/woocommerce.9.3.4.zip --force
# Test
# Bug gone? You've found it. Now read the WooCommerce changelog.
The version rollback from a known-good state is usually the cheapest possible test.
Step 3: Form Cheap Hypotheses
Once you’ve scoped to a single plugin or a handful of files, you’re in hypothesis territory. Good hypotheses are specific and cheap to test.
Bad hypothesis: “Something is wrong with how variations are saved.”
Good hypothesis: “The save_variations hook is throwing a memory error because it’s loading all variations into memory at once instead of batching.”
The good hypothesis tells you exactly what to check, which file to open, and what the fix might look like. The bad hypothesis just tells you to keep poking.
Senior developers form good hypotheses by knowing where WordPress actually keeps its bugs. In 2026, the usual suspects:
- Memory exhaustion on large admin screens — WooCommerce variations, ACF repeater fields with 100+ rows, custom field processing in loops
- Race conditions in Action Scheduler — a scheduled action triggers before the data it depends on exists
- Object cache inconsistency — wp_cache_get() returns stale data because a plugin wrote to the database without invalidating the cache
- Transient mismatches — a transient exists but its contents are from a previous plugin version
- Hook priority conflicts — Plugin A adds a filter at priority 10, Plugin B also at priority 10, and order depends on which one registered first (which depends on load order)
- Late-firing hooks — something hooked to init is modifying the user, but the user was already resolved on plugins_loaded
- HPOS vs legacy data drift — WooCommerce code that reads from both the old postmeta and new orders table and disagrees with itself
Knowing these patterns means your first hypothesis is usually right. That’s what “experience” actually is — pattern matching to the usual suspects and starting with the highest-probability one.
The Tools, In Priority Order
Now we can actually talk about tools. Every tool below exists to make one specific step in the debug loop faster. Use them for that purpose, not as a ritual.
Tool 1: Query Monitor (But Stop Using It Wrong)
Query Monitor is the single highest-value debugging tool in the WordPress ecosystem. It’s also misused by about 90% of developers who install it.
Install it. Never on production. Only on local and staging:
wp plugin install query-monitor --activate
Here’s what most developers do with Query Monitor: they look at the queries tab, see a big number, feel bad, and close it. That’s not debugging.
Here’s how to actually use it:
- The Hooks panel is the most underused panel in Query Monitor. It shows every action and filter that ran on the current page, in order, with the callbacks attached to each. When you’re debugging “which plugin is modifying this?” this panel is the answer. Sort by filter name, find the filter you care about, see the exact file and line of every callback.
- The Capability Checks panel shows every current_user_can() call. When you’re debugging permission issues (“why is this user seeing a 403?”), this is gold — you see exactly which capability was checked and whether the user had it.
- The HTTP API Calls panel shows every external HTTP request the page made. When a page is slow, check here first. Nine times out of ten, something is hitting a third-party API synchronously and that API is taking 3 seconds. Fix: wp-cron it, or cache it.
- The Queries by Caller panel is better than the Queries panel for finding slow code. It groups queries by the function that called them, so instead of “here are 800 queries” you get “here’s the 1 function that made 750 queries in a loop.”
You can also log your own data to Query Monitor from anywhere in your code:
do_action('qm/debug', 'User role at this point: ' . wp_get_current_user()->roles[0]);
do_action('qm/error', 'This should never happen');
do_action('qm/warning', 'Cache miss on ' . $cache_key);
This is 100x more useful than error_log() during active debugging because you see the context (the HTTP request it happened on, the user, the queries that ran, the hooks that fired) all in one place.
Tool 2: Xdebug (When You Actually Need It)
Xdebug is heavy machinery. Most WordPress debugging does not require it. When you do need it, nothing else comes close.
Use Xdebug when:
- You need to see the exact state of variables at a specific line of execution
- You need to step through a complex function to understand what it’s doing
- You’re debugging someone else’s code and can’t easily add logging
- You’re debugging a hook chain with 10+ callbacks and need to see what each one does to the filter value
Don’t use Xdebug when:
- A qm/debug call would tell you the same thing faster
- You already have a hypothesis — Xdebug is for exploration, not confirmation
- You’re on a remote server — configuring remote Xdebug is a rabbit hole you don’t want
The right setup in 2026 is Local by Flywheel or DevKinsta with Xdebug pre-installed (both support it out of the box), VS Code with the PHP Debug extension, and a workspace launch.json config that points at your site:
{
"version": "0.2.0",
"configurations": [
{
"name": "Listen for Xdebug",
"type": "php",
"request": "launch",
"port": 9003,
"pathMappings": {
"/www/site/public": "${workspaceFolder}/public"
}
}
]
}
Set a breakpoint. Trigger your reproduction. VS Code pauses execution and gives you variables, call stack, and a REPL. It’s magic when you need it.
The one Xdebug feature most developers never use is conditional breakpoints. Right-click a breakpoint, add a condition like $post->post_type === 'product' && count($variations) > 20, and the breakpoint only triggers when that condition is true. For intermittent bugs, this is the only way to debug without losing your mind.
Tool 3: A Proper Logger, Not var_dump()
Stop using var_dump(). Stop using print_r(). Stop using error_log(print_r($x, true)). All three are what debugging looked like in 2008 and we should have moved on.
Drop this must-use plugin into every environment you debug in:
<?php
/**
* wp-content/mu-plugins/debug-logger.php
* A slightly-less-awful logging helper.
*/
if (!function_exists('swlog')) {
/**
* Log to a dedicated debug file with caller context.
*
* @param mixed $data Anything. Scalars, arrays, objects.
* @param string $tag Optional tag to filter on later.
*/
function swlog(mixed $data, string $tag = 'debug'): void {
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
$caller = $trace[0] ?? [];
$file = basename($caller['file'] ?? 'unknown');
$line = $caller['line'] ?? 0;
$payload = is_scalar($data) || is_null($data)
? var_export($data, true)
: json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$log = sprintf(
"[%s] [%s] [%s:%d] %s\n",
gmdate('c'),
$tag,
$file,
$line,
$payload
);
error_log($log, 3, WP_CONTENT_DIR . '/debug-' . $tag . '.log');
}
}
Now you can log from anywhere:
swlog($order->get_items(), 'checkout');
swlog(['user' => $user_id, 'capability' => $cap], 'auth');
swlog($wpdb->last_query, 'sql');
And tail each tag separately:
tail -f wp-content/debug-checkout.log
This gives you structured, tagged, timestamped logs with file and line context. It’s five minutes of setup that will save you a hundred hours of scrolling through debug.log.
The single best addition to this logger is the backtrace variant. When you’re trying to figure out “what code is calling this function?”, log the call stack:
swlog(wp_debug_backtrace_summary(), 'who-called-me');
wp_debug_backtrace_summary() is WordPress core’s built-in helper that returns a readable string of the call stack. It’s the single most underused function in WordPress development and it has saved me more hours than any debugger.
Tool 4: Hook Tracing
When a filter or action isn’t behaving, the question is always “what’s attached to this hook, and in what order?” Core has the answer:
global $wp_filter;
swlog($wp_filter['the_content'] ?? 'not hooked', 'hook-trace');
That dumps every callback attached to the_content, with priorities. You can see immediately which plugin is running when, and in what order.
For active debugging, trace the value as it passes through the hook chain:
add_filter('the_content', function($content) {
swlog(['phase' => 'early', 'len' => strlen($content)], 'content-trace');
return $content;
}, 1);
add_filter('the_content', function($content) {
swlog(['phase' => 'late', 'len' => strlen($content)], 'content-trace');
return $content;
}, PHP_INT_MAX);
Now you can see exactly how much the filter chain transformed the content. If the length jumps from 2,000 characters to 40,000 between early and late, something in the chain is injecting 38,000 characters you don’t want. Narrow down by adding more log points at middle priorities until you find the culprit.
Tool 5: Database Query Analysis
When a page is slow, the first question is “how much of it is database?” Query Monitor answers this, but it can’t run on production and it doesn’t show the kind of detail you need for a real slow-query investigation.
Enable the MySQL slow query log:
# my.cnf
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.5
log_queries_not_using_indexes = 1
Tail it during a production incident:
tail -f /var/log/mysql/slow.log
Every query over 500ms gets logged with its execution time and the full query text. When you spot a slow one, grab it and run EXPLAIN against it:
EXPLAIN SELECT * FROM wp_posts
JOIN wp_postmeta ON wp_posts.ID = wp_postmeta.post_id
WHERE wp_postmeta.meta_key = 'something'
AND wp_postmeta.meta_value = 'something else';
If EXPLAIN shows type: ALL and rows: 500000, you have a full table scan. Add the right index and watch it drop to type: ref and rows: 5. This is the single biggest performance win on a typical WooCommerce store with lots of custom fields.
Tool 6: Git Bisect (For Bugs That Broke “Sometime Recently”)
If the bug started “sometime in the last two weeks” and you have a git log with lots of commits, don’t hunt manually. Let git do it:
git bisect start
git bisect bad # HEAD is broken
git bisect good abc1234 # this commit was fine
# git now checks out the commit halfway between
# Test your reproduction
git bisect bad # or good
# Repeat until git finds the exact commit that broke it
Bisect reduces 100 commits to log₂(100) ≈ 7 tests. It’s mechanical. It’s faster than reading code. And the commit it finds is, by definition, the one that introduced the bug. You don’t have to guess anymore.
Real Debugging Walkthrough: “WooCommerce Checkout Is Slow”
Let’s make this concrete. Client email: “Checkout takes 6 seconds to load after clicking Place Order.”
Reproduce. Click Place Order as a test customer. Confirm: yes, 6 seconds. Consistent. Not intermittent. Good.
Form hypothesis 1: Synchronous external API call during order processing. Probability: high. This is the most common cause of slow WooCommerce checkout in 2026.
Test hypothesis 1: Open Query Monitor on staging, trigger the checkout, look at HTTP API Calls.
Found it? If yes, you see a 4-second call to some_shipping_api.com. Done, hypothesis confirmed. Fix: make it async, or cache the rate.
If no, move on.
Form hypothesis 2: A database query on an unindexed meta key. Probability: also high.
Test hypothesis 2: Query Monitor’s Queries panel, sort by duration descending. If you see one query taking 3 seconds, read the query, run EXPLAIN, fix the index.
If no, move on.
Form hypothesis 3: A badly written woocommerce_checkout_order_processed hook callback.
Test hypothesis 3:
add_action('woocommerce_checkout_order_processed', function($order_id) {
swlog(['phase' => 'start', 'order' => $order_id], 'checkout-hook');
}, 1);
add_action('woocommerce_checkout_order_processed', function($order_id) {
swlog(['phase' => 'end', 'order' => $order_id], 'checkout-hook');
}, PHP_INT_MAX);
Look at the timestamps between phase start and phase end. If they’re 4 seconds apart, something in the chain is slow. Find which callback by adding log points at each priority.
This is how senior developers actually work: three cheap hypotheses tested in twenty minutes, instead of three hours of guessing.
Real Debugging Walkthrough: “REST API Returns 401 for One Specific User”
Client email: “User with ID 5423 gets 401 on /wp-json/wc/v3/orders but user 5424 works fine. Both are shop managers.”
Reproduce. Use Postman with application password auth, both user IDs, same endpoint. Confirm: 5423 gets 401, 5424 gets 200.
Hypothesis 1: Application password is revoked or doesn’t exist on user 5423.
wp user application-password list 5423
If empty, that’s your bug. Create one.
Hypothesis 2: User 5423 has lost the capability required by the endpoint.
wp user get 5423 --field=roles
wp cap list shop_manager
If roles are missing or the capability manage_woocommerce isn’t in the role, there’s your bug.
Hypothesis 3: A custom filter is blocking the user. Check rest_authentication_errors:
grep -rn "rest_authentication_errors" wp-content/
If a plugin is returning an error from that filter based on some condition that only applies to user 5423 — user meta, IP, custom flag — you’ve found it. Probability of this being the bug rises sharply if hypotheses 1 and 2 are clean.
Three precise tests. Total time: five minutes. Same bug takes an inexperienced developer half a day because they don’t know the filters exist.
Diagnostic Plugins: The Senior Move
Junior developers debug. Senior developers ship diagnostics.
When you find a bug and fix it, the next question is: “how do I prevent the next person from having to debug this?” The answer is usually a diagnostic plugin — a small piece of code that detects the problem condition and either logs it, warns, or auto-fixes it.
Example: if you’ve just spent three hours debugging an autoload bloat issue, don’t just fix it and move on. Ship this:
<?php
/**
* wp-content/mu-plugins/diagnose-autoload.php
* Logs a warning if autoload size exceeds 1MB.
*/
add_action('admin_notices', function(): void {
if (!current_user_can('manage_options')) {
return;
}
$size = wp_cache_get('swp_autoload_size');
if ($size === false) {
global $wpdb;
$size = (int) $wpdb->get_var(
"SELECT SUM(LENGTH(option_value)) FROM {$wpdb->options} WHERE autoload = 'yes'"
);
wp_cache_set('swp_autoload_size', $size, '', HOUR_IN_SECONDS);
}
if ($size > 1024 * 1024) {
$mb = round($size / 1024 / 1024, 2);
printf(
'<div class="notice notice-warning"><p>Autoload size is %sMB (target: under 1MB). Investigate wp_options before this becomes a performance problem.</p></div>',
esc_html((string) $mb)
);
}
});
The next time this problem is about to happen, the dashboard tells the site owner before it gets bad. That’s the difference between debugging a single bug and eliminating a whole class of bugs.
When to Stop Debugging and Start Rebuilding
Sometimes the right call is to stop.
If you’ve been debugging the same bug for more than four hours, stop. Ask yourself: “is the bug in my code or in a plugin I don’t control?”
If it’s in your code and you’re four hours in, the code has a structural problem. You’re trying to fix something that was built wrong. The right move is often to rewrite the offending function or module, not to keep patching it. A two-hour rewrite beats a six-hour patch.
If it’s in a plugin you don’t control — a premium plugin with closed source, a free plugin with an unresponsive maintainer — stop debugging and find a different plugin, or fork it. The time you spend debugging someone else’s buggy code is time you could have spent writing something reliable.
The senior move isn’t always to solve the bug. Sometimes it’s to route around the bug entirely.
The Debugging Checklist
When a bug lands on your desk in 2026, the workflow looks like this:
- Get a precise reproduction. Write it down.
- Check the error log. Actual log, not the dashboard.
- Binary search the scope (plugin, theme, custom code).
- Form a specific hypothesis based on pattern matching to common WordPress bugs.
- Test the hypothesis with the cheapest possible experiment — Query Monitor, a log line, or a conditional breakpoint.
- Repeat step 4-5 until you find the cause.
- Fix it with a minimum-diff patch.
- Write a regression test or a diagnostic plugin so this never bites again.
- Document what you found — in a comment, a commit message, or an incident doc.
Every senior developer I’ve worked with has some version of this loop running in their head. The tools change. The methodology doesn’t.
Most of the WordPress “rescue” work we do at SwiftlyWP — sites where a previous developer left behind a codebase nobody can debug — comes down to this methodology. Reproduce, isolate, hypothesise, test, fix, protect. If you’ve got a project stuck in debugging hell, our Custom WordPress Development service starts at $999 and includes codebase triage as part of every engagement. Sometimes the fastest way out is a fresh pair of senior eyes.
Debugging is a skill, not a tool. Spend the next month practising the methodology on every bug you hit — even the easy ones — and you’ll stop being the person who spends an afternoon on something a senior solves over coffee.