Skip to content
System nominalUTC --:--:--Articles 041

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
Technical cover graphic for Closing the Gap Between a Bug That Will Not Reproduce and Production
Generated schematic — TOOL series, REV 2.6.

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.

DifferenceBug it causesWorth matching?
PHP minor versionDeprecations, argument errors, array ordering, fatal on new syntaxYes, always
Loaded extensionsFatal undefined function on one code path onlyYes, the list the site uses
Object cache presentStale reads that depend on the cache, recompute count, cold start racesYes, both states
Database engine and majorDifferent query plans, index behaviour, sorting and collationYes, the engine at least
Error reporting levelSilent failures locally invisible, notices in production logYes, test with it off
File permissionsUpload and cache directory write failuresShape only
Active plugin setInteraction bugs, load-order dependent behaviourKeep in version control
CPU count, hostname, clockTiming artefacts and URL comparison bugs, mostlyNo

What to do on Monday

  1. Run php -v locally 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.
  2. Compare the loaded extension lists with php -m on both, and add the ones the site actually calls into.
  3. Run the site locally once with debugging output off, and fix whatever breaks.
  4. Find out whether production has a persistent object cache, then set your local environment to the same state and leave the toggle documented.
  5. Check whether your local database engine matches the server's. If it does not, change the engine, not the code.
  6. 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

Related in Tooling & Workflow