DEV Community

Cover image for Why changing your WordPress domain corrupts the database
Ibrahim Hajjaj
Ibrahim Hajjaj

Posted on

Why changing your WordPress domain corrupts the database

You are moving a site from example.com to example.org. You run a search and replace over the database. Most of the site comes back fine, and then the homepage is blank, or a page builder layout is empty, or a widget area has quietly vanished.

This happens because a WordPress database is not a pile of strings. Three separate encodings are stacked inside single column values, and a plain substitution corrupts all three in different ways.

The byte counts

PHP serialization stores the length of every string alongside it:

a:1:{s:3:"url";s:23:"https://example.com/img";}
Enter fullscreen mode Exit fullscreen mode

That s:23 is a byte count. Replace example.com with a-much-longer-domain.com as plain text and you get:

a:1:{s:3:"url";s:23:"https://a-much-longer-domain.com/img";}
Enter fullscreen mode Exit fullscreen mode

The string is now 36 bytes and still claims to be 23. unserialize() returns false, WordPress gets nothing back where it expected an array, and whatever read that option silently renders empty.

The fix is not to recalculate the numbers with a regex. It is to never work on the serialized text at all: unserialize, walk the structure, replace inside the leaf strings, then re-serialize and let PHP write the lengths.

One detail matters when you do this. Unserializing attacker-controlled or just old data can instantiate objects:

$decoded = @unserialize( $value, array( 'allowed_classes' => false ) );
Enter fullscreen mode Exit fullscreen mode

allowed_classes => false turns every serialized object into a __PHP_Incomplete_Class instead of constructing it. You are reading arbitrary rows out of a database you may not have written. Do not construct their objects.

The JSON inside the serialization

Page builders make this worse. Elementor stores an entire layout as a JSON document, and then stores that JSON document as a string inside a serialized value. So the structure is: serialized PHP, containing a string, containing JSON, containing your URLs.

Unserializing gets you the JSON as one opaque string. Your replacement runs against it as text, which brings back the byte-count problem one level down, and it also misses matches entirely, for a reason that catches almost everybody.

The escaped slashes

json_encode() escapes forward slashes by default. So a URL that reads https://example.com in the browser is stored as:

https:\/\/example.com
Enter fullscreen mode Exit fullscreen mode

Search for https://example.com and you match nothing. The URL is right there, visible in the row, and your query returns zero results.

You cannot fix this by searching for the escaped form instead, because now you are back to substituting text inside a JSON string inside a serialized value, breaking the outer byte count and possibly the inner escaping too.

You have to decode the JSON, walk it, replace in the leaves, and re-encode. And when you re-encode, the escaping has to come back the way you found it, because rewriting a row's encoding style is a change you did not ask for and cannot see in a diff:

$flags = JSON_PRESERVE_ZERO_FRACTION;
if ( false === strpos( $value, '\\/' ) ) {
    $flags |= JSON_UNESCAPED_SLASHES;
}
if ( false === strpos( $value, '\\u' ) ) {
    $flags |= JSON_UNESCAPED_UNICODE;
}
Enter fullscreen mode Exit fullscreen mode

Read that the right way round. It does not decide how slashes should be escaped. It looks at what the original value did and matches it. If the stored JSON had \/ in it, the output keeps \/. If it had \uXXXX unicode escapes, the output keeps those too. Whatever wrote that row gets its own convention back.

Knowing when to refuse

The hard part is not the replacing. It is noticing when a round-trip through json_decode() and json_encode() would not be lossless, and stopping.

Two cases:

Large integers. JSON has no integer type distinct from float, and PHP's decoder will happily turn a 17-digit order ID into a float and hand it back to you rounded. That is silent data loss in a WooCommerce table.

Duplicate keys. A JSON object with the same key twice is legal to parse and impossible to represent in a PHP array. Decoding keeps the last one. Re-encoding writes a document that is missing data the original had.

Both are detectable before you touch anything:

if ( 1 === preg_match( '/[0-9]{16}/', $value ) || $this->json_has_duplicate_keys( $value ) ) {
    // do not round-trip this value
}
Enter fullscreen mode Exit fullscreen mode

When either is true the right behaviour is to skip the row and say so, rather than write a subtly different document and call it success. Every skip carries a reason:

const SKIP_MALFORMED_SERIALIZED = 'malformed_serialized';
const SKIP_DEPTH_EXCEEDED       = 'depth_exceeded';
const SKIP_LOSSY_JSON           = 'lossy_json';
Enter fullscreen mode Exit fullscreen mode

A tool that reports "42 rows changed, 3 skipped because a lossless round-trip was not possible" is more useful than one that reports 45 changed, because the second one is lying in a way you will discover months later.

Base64, briefly

Some plugins base64 the values they store. Same shape of problem: decode, replace, re-encode, and only when the decode is unambiguous. Strict mode alone is not enough, because plenty of ordinary short strings decode without complaint. The test that actually works is a round-trip:

$decoded = base64_decode( $value, true );

return false !== $decoded && base64_encode( $decoded ) === $value;
Enter fullscreen mode Exit fullscreen mode

If re-encoding the decoded bytes does not reproduce the original character for character, it was not canonical base64 and you should leave it alone.

What this means for doing it at all

Three practical conclusions.

Preview before writing. Every one of these failures is invisible at the moment it happens and expensive later. If a tool cannot show you the matches grouped by table and column, with the changed characters marked, before anything is written, you are finding out what it did by browsing your own site afterwards.

Snapshot the affected rows. Not a full database dump, which nobody takes for a search and replace. Just the rows about to change, so undo is one operation.

Never write to the user tables. Not "excluded by default", which is a checkbox somebody will untick at 2am. wp_users and wp_usermeta should be structurally unreachable, because a replacement that touches them can lock you out of the site you are in the middle of fixing.

The implementation of all of this is GPLv2 at github.com/ibrahimhajjaj/lucid-search-replace, and it is free on wordpress.org. I found the escaped-slash case the way everyone finds it: staring at a URL that was plainly visible in the row while the search returned zero results, convinced for most of an evening that I had lost my mind rather than that json_encode had quietly put a backslash in front of every forward slash.

That is the one to check first in whatever tool you already use. Put a page builder URL in the search box. If it finds nothing while you can see the URL sitting in the row, that tool is not reading the JSON, and the next thing it does to your database will be worse than finding nothing.

Top comments (0)