← Back to blog

What local WordPress development cannot show you

The SandyWP team 7 min read

Local WordPress development is the correct default for writing code. It is fast, it is free, it works on a plane, and nothing you break is public.

It is also the wrong place to find out whether your code runs. Six classes of bug are invisible on localhost by construction, not by misconfiguration, and every one of them first appears on the deploy.

Every guide that ranks for this phrase is a setup guide. We read the WP Engine guide (about 2,000 to 2,500 words, all of it Local's install flow) and Jetpack's comparison of eight environments (about 3,500 to 4,000 words), and between them they discuss zero limitations of local development. This post is the other half.

What local development gets right, so we can stop talking about it

Editing files with no upload step. Breaking things with no consequence. Running several sites at once for nothing. Working with no network.

None of that is in dispute, and none of it is what this post is about. Keep your local environment.

Your filesystem is case-insensitive and the server's is not

This is the single most common way a plugin works locally and 500s in production, and it is a property of the disk, not of your code.

macOS formats APFS case-insensitive by default, and Windows is case-insensitive too. Your Linux server is not.

So require_once __DIR__ . '/includes/Widget.php' finds widget.php on your laptop and finds nothing on the server. The same applies to a template file, an enqueued asset path, and a text-domain directory.

MySQL inherits it. From the MySQL manual on identifier case sensitivity, read 2026-09-06: "the case sensitivity of the underlying operating system plays a part in the case sensitivity of database, table, and trigger names. This means such names are not case-sensitive in Windows, but are case-sensitive in most varieties of Unix. One notable exception is macOS, which is Unix-based but uses a default file system type (HFS+) that is not case-sensitive."

The manual gives lower_case_table_names a default of 0 on Unix, 1 on Windows and 2 on macOS, and says it can only be set when the server is initialised. A query against wp_Options is fine on your machine and fatal on the server, and you cannot fix it after the fact by changing a setting.

You cannot test for this locally. There is no flag. The only test is running the same code on a case-sensitive filesystem.

Nothing on the internet can call localhost back

Outbound requests work fine locally. Your site can reach the Stripe API, the WordPress.org API, and any REST endpoint you like.

Inbound is the problem. A gateway webhook, an OAuth redirect, a /wp-json/ callback from a SaaS tool, an uptime check, and an image CDN fetching an original are all requests that start outside your machine, and none of them can reach http://mysite.local.

The usual answer is a tunnel, and a tunnel is a real answer. It is also a different hostname than the one your site thinks it has, which is its own class of bug: we wrote about what breaks once the tunnel is up rather than repeating it here.

The point for this post is narrower. If your feature has an inbound leg, the local environment tests the half of it you wrote and none of the half the other service performs.

HTTPS is not a detail you add at the end

Most local stacks serve plain HTTP, or HTTPS behind a certificate your browser distrusts and you clicked through.

Three things only exist on a real certificate. Mixed-content blocking, where a hard-coded http:// asset silently fails to load on the live site and loads fine locally. Cookies with the Secure attribute, which the browser will not set over plain HTTP, so a login or cart flow that depends on one behaves differently. And any API that refuses a non-HTTPS redirect URI, which most OAuth providers do.

A site that has never been served over real HTTPS has never run the code path that production runs on every request.

The database is probably not the database you deploy to

Local stacks ship MySQL. A large share of WordPress hosts run MariaDB, and the two have diverged enough to matter.

The divergences that bite are sql_mode defaults, JSON handling, the collation available for your tables, and the exact error a long index throws. A utf8mb4_0900_ai_ci collation from MySQL 8 does not exist in MariaDB at all, which is a whole failure mode we covered separately in the unknown collation error.

Check which one you are deploying to before you assume the local database is representative. If it differs, your migrations and your custom queries are untested.

Everything is fast, and nothing is contended

Local has no network latency, no shared CPU, no disk contention, and usually no object cache and no page cache.

That makes local timings meaningless in both directions. A query that takes 40ms on an SSD with the whole dataset in RAM can take two seconds on a busy shared host, and a slow admin screen locally can be perfectly fine in production.

It also hides concurrency entirely. One person clicking is not two cron events, a REST call and a checkout arriving in the same second, and race conditions in an options write or a transient do not appear when there is exactly one request in flight.

You own every file, and on the server PHP does not

Locally your web server usually runs as you, so every file is writable and every directory is creatable.

On the server PHP runs as a different user, and wp-content/uploads has an owner and a mode. Code that writes a cache file next to a plugin, creates a directory outside uploads, or checks is_writable() on the plugin folder takes a different branch there.

A plugin that "works locally" and cannot write its cache in production has not changed. Only the identity running it has.

The five-minute check before you deploy

Do this on a real server, not on your laptop. It is the same list whichever remote environment you use.

Run the site once on a case-sensitive filesystem, with the debug log on, and load the front page, the admin, and every screen your code adds. Missing-file errors show up here or nowhere.

Load the site over a real HTTPS URL and open the browser console. Mixed-content warnings are the whole test.

Check the database server: wp db query "SELECT VERSION();" tells you MySQL or MariaDB and the version. Compare it to the host you deploy to, not to what you have locally.

Deliver one inbound request from the real third party, not a replayed payload, if your feature has an inbound leg.

And check the write paths as the web user rather than as yourself: sudo -u www-data wp eval 'var_dump(is_writable(WP_CONTENT_DIR));' answers in one line.

Where a disposable remote install fits

Four of those six checks need a real Linux server with a real domain, and standing one up per branch is the friction that stops people doing it.

A SandyWP sandbox is that server. Each one is a real WordPress install on Linux, on a public HTTPS URL with TLS terminated for you, with its own database inside MariaDB, and it can be thrown away when the branch is.

You can push what is already on your laptop into one. sandywp push from a local WordPress folder mirrors the database and wp-content into a sandbox and prints its public URL, and it works from LocalWP, MAMP, XAMPP and the rest. Later pushes update the same sandbox in place. See the CLI.

From there the checks above are ordinary work: SSH and SFTP give you a real shell with WP-CLI preinstalled, the PHP selector covers 8.5 down to 7.4 without touching a config file, and outgoing wp_mail() is captured and shown to you rather than delivered, so a test on copied data cannot email a real customer. That is the email log.

Push is one-way, which is worth saying plainly. Local changes replace anything you edited in the sandbox, and there is no path back from the sandbox to production.

When local is still the right answer

Writing the code. Nothing beats a file save and a reload with no network in between, and a remote environment that adds three seconds to every iteration will simply not get used.

Debugging with a step debugger, which is far easier against a local process. Working offline. Anything where the variable is your code and not the environment it runs in.

The split is clean. Write locally, verify on a real server, and stop treating the first deploy as the first test. The bugs above are not deploy problems, they are just the first moment anyone looked.

The short version

Local development tests your code. It does not test your environment, and six things about the environment are different by construction: the filesystem's case sensitivity, whether anything can reach you, whether TLS is real, which database engine you have, how fast and how contended the machine is, and who owns the files.

Find them on a real Linux server with a real certificate, before the deploy rather than during it. Pick whichever remote environment is cheap enough that you actually use it, because the one you skip catches nothing.

Sources