Last week I was assembling a reconciliation fixture and pasted the order endpoint response into the converter to get a PHP array. It surfaced three days later: the log said user_id 9007199254740993, the seed said 9007199254740992, and the amount 9223372036854775807 came out as 9223372036854776000. Nothing failed, nothing warned, the values simply moved.
I read the template implementation and ran 88 cases through it. The short answer is that almost every change happens in the very first step, JSON.parse, not in the part that assembles the literal.
Where the values change: they land in a JS number first
The chain is short: read the string, JSON.parse, then build 'key' => value per type, and the number branch (template line L107) just stringifies whatever parse returned. Once a value sits inside a double, no amount of pretty indentation brings the original back. 2^53=9007199254740992 is the exact integer ceiling in JS: on the boundary it stays as it is, above the boundary there is no promise at all. The controls are the output format, the indentation and whether to add the <?php tag, and there is no big integer fidelity switch.
| Value in the JSON | Literal produced | json_decode on the PHP side |
|---|---|---|
9007199254740993 | 9007199254740992 | integer |
12345678901234567890 | 12345678901234567000 | double 1.2345678901234567E+19 |
9223372036854775807 | 9223372036854776000 | integer, off by 193 |
The last row stings most: PHP taking the same digits on its own keeps the exact value, it only collapses after passing through JS. Order numbers, snowflake ids and microsecond timestamps are usually bare numbers.
Floats follow no single rule, and infinity is computed
- The 21 digits of
3.14159265358979323846survive as the 16 digits of 3.141592653589793. - Inside 17 significant digits, both
0.30000000000000004and1.2345678901234567come back character for character. - Trailing zeros get eaten:
1.500becomes 1.5,-0becomes 0,1.5e3becomes 1500. - The notation itself changes:
1e20prints 100000000000000000000,1e21prints 1e+21,1e-324underflows to 0.
A huge exponent computes an infinity instead: 1e999 gives Infinity and -1e999 gives -Infinity. PHP has no Infinity literal and the constant is INF, and I did not run php -l in this round, so I will not claim whether that line even compiles.
null, empty containers, duplicate keys, key order
{} and [] both come out as []; switch to the PHP object mode and the empty object does not even keep the (object) prefix, so {"a":[{},[]]} yields 'a' => [ [], [], ]. Duplicate keys merge without a sound: {"a":1,"a":2,"a":3} leaves only 'a' => 3, and 19 bytes collapse into 14. Numeric looking keys get reordered, so inside {"01":"a","10":"b"} the '10' sits before the '01'. {"z":null} produces 'z' => null and does not collapse, and {"s":"007"} is neither turned into a number nor trimmed.
Chinese keys, \u escapes and quotes
Chinese is no obstacle: {"用户名称":"张三"} produces '用户名称' => '张三', and reserved words, keys with spaces and keys with dashes print as they are. \u is already resolved during the parse phase, so {"s":"\u4e2d\u6587"} comes back as 2 characters. The escaping only covers the backslash and the single quote: {"a'b":"x"} turns into 'a\'b', while a double quote falls into the single quoted string untouched.
What you copy back is not what was produced
The output area renders highlighting with innerHTML and escapes neither angle brackets nor &, yet the copy button takes textContent. {"s":"a<b>c</b>"} copies back as 's' => 'ac', while the same value is 24 characters long before it ever touches the DOM; in {"<?php":"x"} the key name disappears whole and only ' => 'x' is left. So compare the source text against the final array, not against what you pasted back.
Scale and depth: from slow to broken
Figure 1: the three sizes went through one and the same template, one after another, and err was an empty string every time, so there was neither a silent failure nor a truncation warning.
Depth does not make it slow, it makes it break: depth=100 writes 20401 characters, depth=1000 writes 2004001, depth=2000 writes 8008001, and from depth=5000 there is only Maximum call stack size exceeded. Indentation adds 2 spaces per level, so the total length climbs as a square, and the usable stack depth lands between 1000 and 5000; I cannot give a threshold for nested objects, because those cases are invalid input by themselves.
Figure 2: every step up in depth raises the output length by roughly a square.
The failure notice is one line of English
The red box title is fixed as JSON 解析错误 and the body is simply e.message, while the output area and the statistics get cleared together. {"n":007} reports Unexpected number at position 6, {"a":1,} reports Expected double quoted property name at position 7, and {"a":1}{"b":2} reports Unexpected non-whitespace character at position 7, because only one document is read at a time. Blank input shows the empty state only, input carrying a BOM still converts, and whether the notice is localised I have no sample for. When a paste fails at position 1 right away, I doubt the source text first and run it through JSON formatting to confirm whether it is valid on its own.
How I check things now
A whole endpoint response that has to become a PHP array goes through JSON to PHP array, and every number above came out of that same template. After it, three fixed steps:
- Fields above 2^53 never pass JS: read the raw text in PHP, json_decode it there, and export it yourself.
- Once converted, compare lengths instead of appearances, because an unexplained shrink is usually the display layer having eaten something.
- Validate key order and empty containers on their own, the generated code hides them; to work out the nesting of the original I follow how that tree viewer post collapses and locates a node.
The object mode only adds the (object) prefix to the levels that take the object branch, the top level array and the scalars never get one, and the word stdClass does not appear anywhere in the output.
Q: Why does a 20 digit integer always change while 0.30000000000000004 stays untouched?
A: It depends on whether a double can hold the value exactly, not on how strange the digits look. An integer beyond 2^53 can only land on the nearest representable value, whereas 0.30000000000000004 is already what a double prints.
Q: {} and [] convert to the same thing, how do I know which one the source had?
A: The generated code cannot answer that: the two branches produce the same number of bytes, and object mode does not rescue an empty object either, so you have to go back to the source text.
Q: Only half of it came out, was it truncated?
A: First tell apart a missing tail from a missing middle, because angle bracket values lose their middle no matter how small the payload is. The largest sample I ran was 100000 items with 5877811 bytes of output, and that run was neither truncated nor flagged; anything bigger is unmeasured.