Build1 publisher3 min readPublished
A WordPress domain migration breaks on the byte count PHP stored beside every string
Unserializing and re-serializing fixes the byte counts, but it only gets you one layer down, where Elementor's JSON-inside-a-string and json_encode's escaped slashes leave your search term matching nothing at all.
The Engineer · Build desk

What happened
- PHP serialization writes a byte count beside every string, so a value declaring s:23 for a 23-byte URL still declares 23 after a longer domain is substituted and the string has grown to 36 bytes.
- unserialize() then returns false, WordPress gets nothing where it expected an array, and the widget or layout that read that option renders empty without an error anyone sees.
- The dev.to writeup's fix is to leave the serialized text alone entirely: unserialize, walk the structure, replace inside the leaf strings, re-serialize, and let PHP write the lengths.
- Elementor stores an entire layout as a JSON document and then stores that document as a string inside a serialized value, so unserializing hands the JSON back as one opaque string.
- json_encode() escapes forward slashes by default, so a URL stored as https:\/\/example.com does not match a search for https://example.com and the query returns zero rows.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Zero matches stops being evidence of anything: the URL is legible in the row and the query still finds nothing, so grep-based verification of a completed migration proves only that your search term was in the wrong encoding.
- exposure A migration tool reads rows it did not write, which puts object construction from stored data on its attack surface unless allowed_classes is set to false on every unserialize call.
- cost The lossy-JSON guard is deliberately wide, matching any long digit run rather than only the case shown to round-trip badly, so rows that would have been fine end up on somebody's manual list.
Recalculating the counts by hand is possible. It is also a job the encoder will do for free, which is the argument in the dev.to writeup for never touching the serialized text at all [5]. Look at the gap you would be repairing manually: the value declares `s:23`, the replaced string is 36 bytes, so the header understates the payload by 13 [1]. Get one of those wrong anywhere in a nested structure and `unserialize()` hands back `false`, and the thing that read the option renders empty [4].
Below that layer, Elementor's layout arrives as one opaque string [7]. Decode it, walk it, replace in the leaves, re-encode [9]. The re-encode is where the writeup does something worth stealing. The flags are not a house convention; they are computed from the value in front of you. `JSON_PRESERVE_ZERO_FRACTION` always, `JSON_UNESCAPED_SLASHES` only when the original contains no `\/`, `JSON_UNESCAPED_UNICODE` only when it contains no `\u` [10]. Whatever plugin wrote that row gets its own escaping style back. A migration that silently renormalises the escaping of every JSON document in the database will pass its own tests and produce a diff nobody can review.
The harder judgement is deciding not to write. Two things do not survive a `json_decode()` and `json_encode()` round trip. A 17-digit order ID comes back as a rounded float, because JSON has no integer type distinct from float, and that is silent loss in a WooCommerce table [11]. Duplicate keys parse fine, collapse to a single entry in a PHP array, and re-encode as a document missing data the original carried [12]. Both are detectable before anything is written, and the writeup checks for a long digit run and for duplicate keys before it touches the value [13].
Then the refusal has to be legible. Each skip carries a reason constant: `malformed_serialized`, `depth_exceeded`, `lossy_json` [14]. Reporting 42 rows changed and 3 skipped because a lossless round trip was not possible beats reporting 45 changed [15]. Three named rows is a morning of manual work with the reason attached. Forty-five changed rows containing one quietly rounded order ID is a support ticket in four months that nobody traces back to the migration.
Two of the mechanisms here belong to PHP's own encoders rather than to anyone's tool. `serialize()` writes byte counts [2], and `json_encode()` escapes forward slashes unless told otherwise [8]. Those carry over to any migration script in any language that has to read the same rows, which is the part of this I would treat as settled. The thresholds do not carry over. A digit-width trigger and a policy of skipping rather than writing are choices, and they are the two lines a team should argue about before adopting the approach.
What to watch
- Whether the skip reasons are surfaced per row in the output, so an operator can fix the three declined rows by hand.
- Whether the duplicate-key detector is exposed as a standalone audit for teams checking existing WooCommerce rows before any migration.
- Whether a published version names its recursion depth limit, since SKIP_DEPTH_EXCEEDED implies one exists and the writeup does not give the number.