XML ↔ JSON Converter — PHP source
Convert XML to JSON and back, preserving attributes. Validates input and reports errors clearly, runs entirely in your browser, with a shareable link to your exact input.
This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.
<?php
/**
* xml-to-json — bidirectional XML <-> JSON converter.
*
* Language: PHP (8.1+, standard library only — ext/libxml + ext/SimpleXML,
* which are universally enabled in modern PHP)
* Source: CosmoDev polyglot showcase port of the XML-to-JSON tool, ported
* from cli/xml-to-json/xml-to-json.go (the canonical Go CLI twin —
* this port mirrors its contract rather than the TS lib, which wraps
* fast-xml-parser).
* License: display source — part of CosmoDev's polyglot tool pages.
*
* Design goals:
* - Pure + deterministic; never throws (malformed input returns null).
* - Functionally equivalent to the Go twin: same inputs -> same JSON structure.
* - Self-contained: stdlib only (SimpleXML + dom_import_simplexml + json).
*
* Contract (matches the Go twin, encoding/xml + encoding/json):
* - Malformed XML (any parse error, no root, multiple roots) -> null.
* - Attributes become "@_<name>" string keys.
* - A text-only element with no attributes becomes its bare string value.
* - An element with attributes and/or children becomes an array; its direct
* text becomes the "#text" key.
* - Repeated child tags become a JSON array.
*
* Note: SimpleXML is a strict parser (simplexml_load_string returns false on
* mismatched tags and on documents without a single root), so the malformed-
* input contract is just a false-check. Direct element text is gathered via
* dom_import_simplexml (SimpleXML's (string) cast flattens descendant text,
* which would diverge from the Go twin's CharData-only accumulation).
*
* Security note: SimpleXML resolves external entities unless disabled. For
* UNTRUSTED input call libxml_disable_entity_loader(true) (or rely on PHP 8+'s
* default-off entity loading) before simplexml_load_string. This showcase stays
* stdlib-only to mirror the Go twin (encoding/xml) with no Composer deps; the
* live web tool parses in the browser via fast-xml-parser.
*/
declare(strict_types=1);
/**
* Convert a SimpleXMLElement to the JSON value the Go twin produces.
*
* @return string|array<string, mixed>
*/
function _xml_to_json_value(SimpleXMLElement $el): mixed
{
$attrs = [];
foreach ($el->attributes() as $name => $value) {
$attrs["@_$name"] = (string) $value;
}
$children = [];
$hasChild = false;
foreach ($el->children() as $name => $child) {
$hasChild = true;
$val = _xml_to_json_value($child);
if (array_key_exists($name, $children)) {
$cur = $children[$name];
if (is_array($cur) && array_is_list($cur)) {
$cur[] = $val;
$children[$name] = $cur;
} else {
$children[$name] = [$cur, $val];
}
} else {
$children[$name] = $val;
}
}
$text = _xml_to_json_direct_text($el);
if (!$hasChild && empty($attrs)) {
// Pure text element (or empty element) — value is the bare string.
return $text;
}
// attrs first, then children, then #text — matches the Go twin.
$obj = $attrs;
foreach ($children as $name => $val) {
$obj[$name] = $val;
}
if ($text !== '') {
$obj['#text'] = $text;
}
return $obj;
}
/**
* Concatenate the element's DIRECT text (text + child tails), trimmed —
* mirrors the Go twin accumulating CharData tokens within an element.
*/
function _xml_to_json_direct_text(SimpleXMLElement $el): string
{
// dom_import_simplexml exposes the underlying DOM node so we can read only
// the immediate TEXT/CDATA children (SimpleXML's (string) cast would
// flatten ALL descendant text, diverging from the Go twin).
$node = dom_import_simplexml($el);
$parts = [];
foreach ($node->childNodes as $c) {
if ($c->nodeType === XML_TEXT_NODE || $c->nodeType === XML_CDATA_SECTION_NODE) {
$parts[] = $c->nodeValue;
}
}
return trim(implode('', $parts));
}
/**
* Parse a well-formed XML string to pretty-printed JSON, or null if malformed.
*
* PHP's json_encode uses 4-space indentation with JSON_PRETTY_PRINT (no native
* 2-space option); the Go twin uses 2-space. That is a cosmetic formatting
* difference only — the JSON structure is identical.
*/
function xml_to_json(string $xml): ?string
{
$prev = libxml_use_internal_errors(true);
libxml_clear_errors();
$el = simplexml_load_string($xml);
libxml_use_internal_errors($prev);
if ($el === false) {
return null;
}
$result = [$el->getName() => _xml_to_json_value($el)];
$encoded = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $encoded === false ? null : $encoded;
}
/**
* Reports whether a PHP value (from json_decode assoc) represents a JSON object
* (associative array) rather than a JSON array (list). An empty array is
* ambiguous ({}/[]); we treat it as a list, which is the rare empty-element
* edge case.
*/
function _xml_to_json_is_object(mixed $v): bool
{
return is_array($v) && !empty($v) && !array_is_list($v);
}
/** Reports whether a PHP value represents a JSON array (list). */
function _xml_to_json_is_list(mixed $v): bool
{
return is_array($v) && ($v === [] || array_is_list($v));
}
/**
* Render a JSON-decoded scalar as a string for XML text/attribute content
* (mirrors the Go twin's stringify).
*/
function _xml_to_json_stringify(mixed $v): string
{
if (is_bool($v)) {
return $v ? 'true' : 'false';
}
if ($v === null) {
return '';
}
if (is_float($v)) {
return $v == (int) $v ? (string) (int) $v : (string) $v;
}
if (is_int($v)) {
return (string) $v;
}
if (is_array($v)) {
$encoded = json_encode($v);
return $encoded === false ? '' : $encoded;
}
return (string) $v;
}
/** Escape XML special characters (& < > " ') for text/attribute content. */
function _xml_to_json_escape(string $s): string
{
return htmlspecialchars($s, ENT_QUOTES | ENT_XML1 | ENT_SUBSTITUTE, 'UTF-8', false);
}
/**
* Render <tag ...>...</tag> for a value at an indent depth, mirroring the Go
* twin's writeElement: @_ keys -> attributes, #text -> text, other keys ->
* child elements (arrays -> repeated siblings).
*/
function _xml_to_json_write_element(string $tag, mixed $val, int $depth): string
{
$indent = str_repeat(' ', $depth);
$inner = str_repeat(' ', $depth + 1);
$attrParts = [];
$children = []; // [tag, value]
$textPart = '';
$hasText = false;
if (_xml_to_json_is_object($val)) {
$keys = array_keys($val);
sort($keys); // deterministic key order, matching the Go twin's sortedKeys.
foreach ($keys as $key) {
$v = $val[$key];
if (str_starts_with((string) $key, '@_')) {
$name = substr((string) $key, 2);
$attrParts[] = $name . '="' . _xml_to_json_escape(_xml_to_json_stringify($v)) . '"';
} elseif ($key === '#text') {
$textPart = _xml_to_json_stringify($v);
$hasText = true;
} else {
$children[] = [(string) $key, $v];
}
}
} else {
$textPart = _xml_to_json_stringify($val);
$hasText = true;
}
$open = $indent . '<' . $tag;
foreach ($attrParts as $a) {
$open .= ' ' . $a;
}
$noChildren = empty($children);
if ($noChildren && !$hasText) {
return $open . '></' . $tag . ">\n";
}
if ($noChildren) {
return $open . '>' . _xml_to_json_escape($textPart) . '</' . $tag . ">\n";
}
$s = $open . ">\n";
if ($hasText && trim($textPart) !== '') {
$s .= $inner . _xml_to_json_escape($textPart) . "\n";
}
foreach ($children as [$childTag, $childVal]) {
if (_xml_to_json_is_list($childVal)) {
foreach ($childVal as $item) {
$s .= _xml_to_json_write_element($childTag, $item, $depth + 1);
}
} else {
$s .= _xml_to_json_write_element($childTag, $childVal, $depth + 1);
}
}
$s .= $indent . '</' . $tag . ">\n";
return $s;
}
/**
* Build an indented XML string from a JSON string whose root is an object, or
* null if the JSON does not parse or the root is not an object (numbers,
* strings, bools, null, arrays are rejected).
*/
function json_to_xml(string $json): ?string
{
try {
$obj = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
if (!_xml_to_json_is_object($obj)) {
return null;
}
$out = '';
$keys = array_keys($obj);
sort($keys);
foreach ($keys as $key) {
$out .= _xml_to_json_write_element((string) $key, $obj[$key], 0);
}
return $out;
}
// ---------- showcase tests (the canonical suite lives in src/lib) ----------
if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
/** Mirror the src/lib/xml-to-json.test.ts vectors (assert on decoded
* structure so formatting differences don't mask contract drift). */
// 1. simple parse
$one = json_decode(xml_to_json('<root><name>Alice</name></root>'), true);
assert($one['root']['name'] === 'Alice');
// 2. attributes preserved as @_<name> strings
$two = json_decode(xml_to_json('<user id="7"><name>Alice</name></user>'), true);
assert($two['user']['@_id'] === '7');
assert($two['user']['name'] === 'Alice');
// 3. repeated child tags become a JSON array
$three = json_decode(xml_to_json('<list><item>a</item><item>b</item></list>'), true);
assert($three['list']['item'] === ['a', 'b']);
// 4. malformed XML -> null
assert(xml_to_json('<a><b></a>') === null);
assert(xml_to_json('not xml at all') === null);
// 5. json_to_xml containment + attributes + invalid roots
assert(str_contains(json_to_xml('{"root":{"name":"Alice"}}'), '<name>Alice</name>'));
assert(str_contains(json_to_xml('{"user":{"@_id":"7","name":"Alice"}}'), 'id="7"'));
assert(json_to_xml('{not valid json') === null);
assert(json_to_xml('42') === null);
assert(json_to_xml('null') === null);
echo "ok\n";
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →