Following up. Our senior developer debugged this further and forwarded the findings below to me. The empty-object corruption I first reported turns out to be one of several problems in the same eight lines, and the fix I posted earlier needs correcting. All of this was reproduced by running the shipped 1.26.6 method directly (PHP 8.4), and the file was diffed against plugins.svn.wordpress.org/updraftplus/tags/1.26.6/ to confirm it’s released code.
Scope: this search/replace runs on every column of every row of every prefixed table (migrator-lite.php:1032 + process_row()), excluding only posts.guid and two UpdraftPlus option rows — so it reaches third-party plugin tables too.
The problems in class-search-replace.php, lines 419–428
1. JSON objects become JSON arrays. json_decode($data, true) drops the object/array distinction, so json_encode() guesses from the keys on the way back:
{} -> []
{"0":"x","1":"y"} -> ["x","y"]
2. Bare JSON scalars skip replacement entirely. The branch is entered for any valid JSON, but only acts if (is_array($_tmp)). A JSON-encoded string (e.g. update_option() storing a URL) isn’t an array, so nothing happens — and the str_replace() in the else is never reached:
"http://old.example.com" -> "http://old.example.com" (NOT migrated)
3. Numbers are re-encoded lossily. No JSON_PRESERVE_ZERO_FRACTION / JSON_BIGINT_AS_STRING:
{"count":1.0} -> {"count":1}
{"id":12345678901234567890} -> {"id":1.2345678901234567e+19} (unrecoverable)
4. Rows are rewritten even when nothing matched. There’s no “did anything change” guard. The decode/encode round-trip alters escaping (/→\/, unicode) regardless of the search term, process_row() sees a difference at line 310 and writes the row back. So every JSON value in every table is rewritten on every migration — and gets problems 1–3 applied — even when it had nothing to do with the URL:
(search term appears nowhere in the value)
{"tags":{},"ratio":2.50} -> {"tags":[],"ratio":2.5}
5. Related — recursion cap. With max_recursion at 20, deep page-builder JSON loses the deep replacement and still gets its outer object flipped to [] — corrupted for a replacement that didn’t even happen. Fixing #4 covers this, since untouched rows are then left alone.
Correction to my earlier post
Just changing json_decode($data, true) to json_decode($data) is not enough — please don’t apply that alone. Nested values then become stdClass and recurse into the object branch at line 406, whose property-name filter at line 412 skips numeric keys, so {"0":"http://old.example.com"} would silently stop being replaced. That filter is correct where it is (it guards get_object_vars() output for private/protected props), so the JSON needs its own walker instead. Recommended change (drop-in)
Your code now — lines 419–428:
} elseif (is_string($data) && (null !== ($_tmp = json_decode($data, true)))) {
if (is_array($_tmp)) {
foreach ($_tmp as $key => $value) {
$_tmp[$key] = $this->recursive_unserialize_replace($from, $to, $value, false, $recursion_level + 1, $visited_data);
}
$data = json_encode($_tmp);
unset($_tmp);
}
}
Replace it with:
} elseif (is_string($data) && '' !== $data && (null !== ($_tmp = json_decode($data)))) {
if (is_object($_tmp) || is_array($_tmp)) {
$json_changed = false;
$replaced = $this->recursive_json_replace($from, $to, $_tmp, $recursion_level, $visited_data, $json_changed);
// Only re-encode when a replacement actually happened; otherwise leave the
// original string byte-for-byte intact. A flag is used rather than comparing
// $replaced against $_tmp: loose == treats numeric strings as numbers
// ("0" == "0.0"), and strict === on the rebuilt object is never true.
if ($json_changed) {
$encoded = json_encode($replaced, JSON_PRESERVE_ZERO_FRACTION);
if (false !== $encoded) $data = $encoded;
}
unset($_tmp);
} else {
// Valid JSON, but a scalar (number, boolean or quoted string) — still needs
// the plain string replace that the final else branch performs.
$data = $case_insensitive ? str_ireplace($from, $to, $data) : str_replace($from, $to, $data);
}
}
And add this new private method (walks the decoded JSON, preserving object vs. array, without the PHP-property-name filter):
private function recursive_json_replace($from, $to, $data, $recursion_level, $visited_data, &$changed) {
if (0 !== $this->max_recursion && $recursion_level >= $this->max_recursion) return $data;
if (is_object($data)) {
$new = new stdClass;
foreach (get_object_vars($data) as $key => $value) {
$new->$key = $this->recursive_json_replace($from, $to, $value, $recursion_level + 1, $visited_data, $changed);
}
return $new;
}
if (is_array($data)) {
$new = array();
foreach ($data as $key => $value) {
$new[$key] = $this->recursive_json_replace($from, $to, $value, $recursion_level + 1, $visited_data, $changed);
}
return $new;
}
// Strings go back through the main engine so nested serialised/JSON data is still
// handled. Ints, floats, bools and null are returned untouched (no precision loss).
if (is_string($data)) {
$new = $this->recursive_unserialize_replace($from, $to, $data, false, $recursion_level, $visited_data);
if ($new !== $data) $changed = true;
return $new;
}
return $data;
}
Two things in there that look simplifiable but aren’t: the change detection must stay a flag (a loose != would drop "0"→"0.0"; a strict !== on the rebuilt object always reports a change), and the helper is entered at $recursion_level (not +1) so the depth budget matches current behavior. Verified after the patch
{} -> {} (preserved)
"http://old.example.com" -> "http://new.example.com" (now migrated)
{"count":1.0} -> {"count":1.0} (preserved)
{"a":"café","b":"x/y"} (no match) -> {"a":"café","b":"x/y"} (byte-identical, not rewritten)
{"settings":{},"url":"...old.example.com"} -> {"settings":{},"url":"...new.example.com"}
The cases the branch exists for (URLs nested in JSON objects/arrays, URLs inside serialized data) still replace correctly, and nesting-depth behavior is unchanged.
One limitation: integers above PHP_INT_MAX still lose precision in rows that genuinely did contain the search term, since those rows do get re-encoded — fixing that fully needs JSON_BIGINT_AS_STRING and felt out of proportion for a minimal patch. The patch’s win is that this no longer happens to every JSON row regardless of relevance.