Closing the Gap Between a Bug That Will Not Reproduce and Production
PHP version, extensions, object cache, database engine and WP_DEBUG explain most bugs that happen on the server and not on your laptop. Matching six is enough.
Devon Parker · @dparker
APIs and integrations
- Topic
- TOOL
- Section
- Tooling & Workflow
- Published
- 08 APR 2026
- Reading time
- 8 MIN
- Words
- 1968W
The bug is on the server. You have the URL, you have the customer's account, you have the sequence of clicks that produced a wrong total. You run the same sequence locally and it works. Not "mostly works": it works, the total is correct, the notice you expected never appears. So you push a speculative change, deploy it, and wait to see whether the customer complains again.
Most of the time the difference is not in the code at all. A local environment drifts away from the server because nothing forces the two to agree, and the drift accumulates in a specific order of consequence: PHP version, extension list, whether a persistent object cache exists, database engine, error reporting settings, permissions, and which plugins are active. Those seven differences explain a large share of "works on my machine". The list below is ranked by how often each one causes a bug.
PHP version and extensions change the code that runs, not merely the speed
This is the highest value difference and the cheapest to eliminate. A PHP version mismatch is not
a performance question, it is a semantics question. Code that runs on one minor version can throw
deprecation notices on the next, and those become exceptions the moment anything runs a strict error
handler. Passing null to an internal function expecting a string is accepted quietly on
older versions and warns loudly on newer ones. Array ordering and sort stability have changed
across releases too, which matters when your code compares two arrays with === and
element order decides the result.
The extension list is the second half of the same problem, and the half people forget. WordPress
core runs without them, so a laptop with a bare PHP install boots a site perfectly well. Then you
write code that leans on an extension your production image has and your laptop does not, or the
reverse. The failure is a fatal Call to undefined function when the code path executes,
which on a checkout page means a white screen rather than an error message. If you match one thing,
match PHP major and minor and the list of loaded extensions.
Image handling is where this bites hardest. A store processing uploaded product images needs the image extension present and configured; an environment where it is missing accepts the upload and fails later, on a different request, looking like a storage problem rather than a missing library.
The object cache is present locally and absent in production, or the reverse
This difference does more than change how fast code runs. It changes which code runs and how often. With a persistent object cache, a function that recomputes an expensive value is called once per cache lifetime. Without one, it is called on every request. Code that is correct with the cache and subtly wrong without it passes every local test you write.
The failure is usually an assumption about state. A value your code writes to the cache and reads back a moment later is genuinely present on a machine with a persistent cache. On a server without one, that value lives only in the request's memory, and a second process handling a background job will not see it. The pattern appears in reverse too: against a warm cache you never notice that a recompute has a side effect, and on the first request after a deploy that side effect fires once per concurrent worker.
A cache that hides an N+1 query loop has not fixed it. It has moved the cost to the cold path, which is the worst place to find out.
Match the state, not the software
Whether Redis is installed locally matters far less than whether the site behaves as though a persistent object cache is present. Turning it on and off locally is a one-line change, and that toggle is more useful than pinning a specific server version.
Error reporting settings hide the failures you are trying to reproduce
Almost every local WordPress install runs with debugging output on and error reporting set wide. Almost every production install runs with both off. The asymmetry is deliberate in each place, and it means the two environments disagree about what counts as a problem.
So the notices scrolling past locally are the notices production writes silently to a server log nobody reads. An undefined array key on a checkout page produces a colourful warning locally. In production, the same line produces a log entry, and the customer sees a total of zero because the code that depended on that key took the silent branch.
The fix costs nothing and almost nobody does it: run the site locally once with debugging output off and error reporting at production's level, and watch what breaks. On a 3,000 order store I would expect a plugin load on a modern PHP version to produce a handful of notices, and each is a call path that behaves differently on the server.
Where the differences run out of consequences
Below the top four, the ranking flattens quickly and the cost of matching goes up.
File permissions and the plugin list
File permissions produce a specific class of bug rather than a general one: uploads that fail, plugins that cannot write to their own directory, a cache directory the web server user cannot create. Matching them exactly is unpleasant on macOS, where the permission model differs from every Linux server you deploy to. Matching the shape is enough (the web server user owns the uploads directory and can write to it) and the exact mode bits are not worth the afternoon.
The active plugin set is a project management problem rather than a technical one. A local environment with eleven plugins and a production site with twenty-three will miss bugs that exist only in the interaction between plugins. Syncing the list is worth doing once, and it stops being worth doing the moment two developers disagree about which plugin is optional.
The differences that cost effort and return nothing
The number of CPU cores does not change whether your code is correct, though it changes how long
you wait. Memory limits matter when they are low enough to kill a request, which is worth
reproducing once. The hostname, the absolute path to the web root, the timezone of the server clock
and the patch release of the database server are differences people spend weeks eliminating. I have
never seen the hostname cause a bug, with one exception: code that compares a string against
home_url() behaves differently when the URL changes, and that is a code problem, not an
environment problem.
Perfect parity is a project with no finish line
Every difference you eliminate is real work, and the returns diminish sharply after the first handful. Chasing the last few percent means version-pinning things nobody has seen break and building an environment so faithful that nobody can start it on a Tuesday without reading documentation. It will still not be production, because production has the traffic and the accumulated state that no seed script generates.
The workable rule is to pick the five differences that have actually caused a bug in your project's history and match those, with PHP version, the extension list and the object cache toggle at the front of the queue because they are cheap. Keep a short list in the repository of the ones that bit you. That list is worth more than a perfect environment definition, because it is specific to the system you maintain.
A container definition with the pinned PHP version and the extensions
The cheapest way to hold all of that is a container definition in the repository, with the PHP version pinned to a minor release, the extensions the site loads installed explicitly, and the same database engine and major version as production. Add the object cache as a service that runs whether or not the site uses it, so switching it on and off never means a rebuild. Everything else, including the exact CPU count, is noise.
# docker-compose.yml, abridged to the parts that matter
services:
php:
image: php:8.2-fpm-alpine # pinned minor, not "php:8"
volumes:
- ./site:/var/www/html
environment:
PHP_MEMORY_LIMIT: 256M
OBJECT_CACHE: "off" # the toggle, read by wp-config.php
depends_on:
- db
- redis
db:
image: mariadb:10.6 # same engine family and major as production
environment:
MARIADB_ROOT_PASSWORD: local-only
redis:
image: redis:7-alpine # running, but not necessarily enabled
Two details earn their keep. The image tag names a minor version rather than a floating major, so a rebuild next month does not silently move the site to a new PHP release mid-session. And Redis is running whether or not the site uses it, which makes toggling useful rather than a rebuild.
The toggle belongs in configuration, and it should be a constant the site reads the way it reads
the production value. Verify rather than trust it: run wp cache flush, then load the
same page twice and check that your own instrumentation reports the same number of database queries
on the second load as the first. If it does, no persistent cache is in play. If the second load is
cheaper, the cache is on and your environment is not the one you thought it was.
A seed dataset that is the right shape and the wrong size
Copying the production database to a laptop is slow, exposes personal data, and usually breaks because half the rows reference media that was never copied. You need a subset with the same shape.
Shape means the distribution, not the volume. For a store: how many products have variations, how many have more than one category, how long the order meta tables are relative to the orders table, how many orders sit in each status. A seed of 400 products where 300 are variable reproduces a query planner's decisions far better than a random 4,000. Keep the seed script in the repository, generate it from production with the values mangled or replaced, and review it when the schema of the important tables changes.
The temptation to skip this is strong, because a seed script is boring work that produces no visible feature. It is also the difference between an environment that reproduces an indexing problem and one that returns correct results from a table small enough to scan.
| Difference | Bug it causes | Worth matching? |
|---|---|---|
| PHP minor version | Deprecations, argument errors, array ordering, fatal on new syntax | Yes, always |
| Loaded extensions | Fatal undefined function on one code path only | Yes, the list the site uses |
| Object cache present | Stale reads that depend on the cache, recompute count, cold start races | Yes, both states |
| Database engine and major | Different query plans, index behaviour, sorting and collation | Yes, the engine at least |
| Error reporting level | Silent failures locally invisible, notices in production log | Yes, test with it off |
| File permissions | Upload and cache directory write failures | Shape only |
| Active plugin set | Interaction bugs, load-order dependent behaviour | Keep in version control |
| CPU count, hostname, clock | Timing artefacts and URL comparison bugs, mostly | No |
What to do on Monday
- Run
php -vlocally and on the server, and make the local version match the server's major and minor release. An hour of work, and it removes the largest class of difference. - Compare the loaded extension lists with
php -mon both, and add the ones the site actually calls into. - Run the site locally once with debugging output off, and fix whatever breaks.
- Find out whether production has a persistent object cache, then set your local environment to the same state and leave the toggle documented.
- Check whether your local database engine matches the server's. If it does not, change the engine, not the code.
- Start a file listing the differences that have caused a bug. Add to it when one does.
Devon Parker · @dparker
APIs and integrations
Works through REST contract design, webhook reliability, idempotency keys, retry policy and the unglamorous edge cases that appear when two systems disagree about what happened.
Continue