
Rubber duck debugging in PHP: narrate the sequence, not the lines
Narrate the execution, not the file: what fires, in what order, in whose process. The four questions that find hook, observer and queue bugs in WordPress and Laravel.

To rubber duck debug PHP, narrate the execution instead of the file, out loud, in four steps: (1) where does this start — request, cron tick, queue worker, webhook, WP-CLI; (2) what fires between the start and my code — every hook, middleware, observer and listener, named, not “the usual stuff”; (3) what do I believe is true when my code runs; (4) whose process is this, and does it run once. Reading the file aloud line by line only makes you confident about a fragment, because in WordPress and Laravel the program is assembled at runtime and the bug is usually in step two or step four.
Everyone has met the duck. You explain a bug out loud to an inanimate object, and somewhere around the third sentence you stop, because you have heard yourself say something that is not true. Andrew Hunt and David Thomas popularised the technique in The Pragmatic Programmer in 1999, describing a developer who carried a duck and explained his code to it line by line. It works because speech is slower than reading, and because explaining forces you to state intent, not just mechanism.
I want to keep the mechanism and change the unit. Because in the PHP I work in — WordPress plugins with large install bases and Laravel applications that take money — the line is the wrong unit, and line-by-line narration has a poor hit rate. It took me a while to work out why.
The lines are not the program
Open a WordPress plugin’s class-license-handler.php and read it aloud. You will narrate a method that validates a licence key and returns a boolean. Every line is correct. The bug is that something else called add_filter( 'pre_http_request', ... ) in a completely different file, and your validation request never left the server.
Same in Laravel. Read a SubscriptionController aloud and every line is right. The bug is an observer on the model that fires on updated, dispatching a job that a queue worker picks up while it is still holding a copy of the framework it booted three deploys ago.
This is the shape of most of the hard PHP bugs I have been paid to find. The code in front of you is not wrong. The code in front of you is incomplete, and the missing part is not on screen. WordPress makes this explicit with hooks; Laravel makes it elegant with events, observers, middleware and the container. Both mean the same thing for debugging: the program is assembled at runtime and the file you are reading is a fragment of it.
None of that is unique to PHP — Rails callbacks, Django signals and Node middleware produce the same problem. It is just that WordPress and Laravel are where I meet it daily, and where the specific hooks worth naming are ones I can name.
Reading a fragment aloud, carefully, in order, will make you more confident about the fragment. That is worse than useless when the fragment was never the problem.
Ask the duck a different question
The version that works is not “what does this line do”. It is:
What happens, in what order, in which process — and what do I believe is true at each step?
You are no longer narrating a file. You are narrating an execution.
One: where does this start
A request? A cron tick? A queue worker? A webhook from Stripe? WP-CLI? Say it.
A lot of the bugs I find in scheduled work die right here, because wp-cron is not cron — it fires on a page load, so on a quiet site it fires late or not at all, and the developer has spent two days debugging the job body. Say which one you have, though: if the host sets DISABLE_WP_CRON and runs a real system cron, as most managed WordPress hosts do, your problem is the opposite one. It fires exactly on time, and it can now fire twice at once.
Two: what fires between the start and my code
Every hook, middleware, observer and listener in between. Out loud. Not “the usual stuff” — name them. This is the step people skip because it is boring, and it is the step that finds the bug.
Three: what do I believe is true when my code runs
That the user is authenticated. That the licence row exists. That $order->total is in cents. That this runs once. Every one of those is an assumption, and saying it out loud is what turns it back into a question.
Four: whose process is this
The web request and the queue worker are different PHP processes with different memory. Laravel is explicit about it: queue workers are long-lived, so they do not see changes to your code until they are restarted. The worker is running whatever your last queue:restart gave it, which is not necessarily what you deployed.
Saying “and then the job runs — in a different process, holding an older copy of the app” is the sentence that has saved me the most hours.
What that sounds like on a real bug
A licensing plugin. Customers report that a small number of renewals do not extend the expiry date. Not all — a few, unpredictably. The renewal handler looks fine and has looked fine to three people.
Line-by-line narration produces: “it loads the licence, adds a year, saves.” Correct, and useless.
Sequence narration produces something like this, and I mean said out loud, badly, to nobody:
The payment provider sends a webhook. It hits my route. Which can arrive more than once, because delivery is at-least-once and providers redeliver when they do not get a timely 2xx. Then middleware runs. Then I look up the licence by key. Then I add a year to the current expiry. Then I save. And I believe this runs once per payment.
The handler is right. The assumption that it runs once is wrong, and the two failure modes look nothing alike. When a redelivery lands while the first attempt is still running, both reads see the same current expiry, both add a year, and the second write overwrites the first — the customer paid once, got one year, and the renewal silently did nothing the next time round. When the redelivery lands after the first write commits, they stack and add two years, and nobody files a ticket about getting too much.
Only the first one matched the reported symptom. I found it by saying a sentence I had not planned to say.
The fix was not in the handler at all. The renewal is now keyed on the provider’s event id behind a unique constraint, so a redelivery hits the index and returns 204 without touching the expiry — the same database-enforced idempotency I reach for in any payments path. The duplicate event ids had been in the logs the whole time. Nobody had looked, because nobody had said the sentence that made them worth looking for.
Why saying it beats thinking it
Reading code you wrote is closer to recognition than to reading. You see the shape of your own foreach, it matches your memory of what it should be, and you move on. That is efficient and it is exactly the failure mode you need to break.
Speech will not let you do it. You cannot say “and then the job runs” without your own brain asking where. The duck is not analysing anything. The duck is a device for making you use a slower, more honest part of your attention.
Which is also why the duck beats a colleague for the first pass. A colleague fills in your gaps — they know your codebase, they nod at “and then the usual auth stuff”, and the assumption you needed to hear survives untested. The duck nods at nothing.
Where it does not help
It will not find a bug in code you have never read. If the failure is inside a dependency, or in a part of the plugin you have not opened, you cannot narrate a sequence you do not know yet. Read first, narrate second.
It will not survive being done in your head. Every time I have thought “I will just do this mentally”, I have skipped step two — the boring one, the one that finds the bug. Out loud, or written down, or typed into a scratch file. The medium is not the point; not doing it silently is.
And it will not replace evidence. Once the duck gives you a candidate, go and prove it: log the webhook id, count the rows, check whether the transient you assumed expired is actually gone — in wp_options if the site has no object cache, and in Redis if it does, which is a different query and the reason people give up on that one. The duck produces hypotheses, and a hypothesis you did not verify is just a more confident guess. That is the same discipline that makes boring code worth defending — you want the version whose behaviour you can check, not the version you find convincing.
The version I would give a junior PHP developer
Skip the duck. Open a text file next to your editor and fill this in, in plain sentences:
1. This starts when ________.
2. Before my code runs, these things fire: ________.
3. When my code runs, I believe ________ is true.
4. This runs in the ________ process, once / more than once.
Filled in on the renewal bug above, it looked like this:
1. This starts when the payment provider POSTs to /webhooks/payments.
2. Before my code runs, these things fire: route middleware, signature
verification, the subscription observer on `updated`.
3. When my code runs, I believe the licence row exists and the expiry
is the one the customer is paying to extend.
4. This runs in the web process, and I believe once. <- this line was the bug.
In WordPress and Laravel, more often than not, one of those four lines is the bug — and it is usually line two or line four.
Line-by-line narration makes you confident about a fragment. In WordPress and Laravel the program is assembled at runtime, so the fragment was never the bug. Narrate the execution instead: where it starts, what fires before your code, what you believe is true when it runs, and whose process it runs in. Then go and prove the candidate with a log line or a row count, because an unverified hypothesis is just a more confident guess.
FAQ
Do not read the file aloud line by line. Narrate the execution: say where the request starts, every hook, middleware, observer and listener that fires before your code, what you believe is true when your code runs, and which process it runs in. In WordPress and Laravel the bug is usually in the order, not the lines.
Because the file you are reading is a fragment. Hooks, events, observers, middleware and queued jobs mean the program is assembled at runtime from files you never opened, so reading that fragment carefully only makes you more confident about the part that was never wrong.
Where does this start. What fires between the start and my code. What do I believe is true when my code runs. Whose process is this, and does it run once. In WordPress and Laravel the bug is most often in question two or question four.
No. Doing it silently is how you skip the boring step, which is naming every hook and listener in between, and that is the step that finds the bug. Say it out loud, write it down, or type it into a scratch file.
Andrew Hunt and David Thomas popularised it in The Pragmatic Programmer in 1999, describing a developer who carried a rubber duck and explained his code to it line by line. The book popularised the anecdote; the name came from the community afterwards.
When the failure is in code you have never read, because you cannot narrate a sequence you do not know. Read first, narrate second. And it never replaces evidence: once the duck gives you a candidate, log the webhook id or count the rows and prove it.