How to convert XML to JSON in PHP while preserving attributes
The other direction of the same problem: you're consuming an XML API — a legacy SOAP service, a supplier feed, a government data source — and you want to work with it as normal PHP data, or hand it to a frontend that only wants JSON.
PHP makes the first step look deceptively easy:
$xml = simplexml_load_string($xmlString);
$json = json_encode($xml);
Try it on anything beyond the simplest XML and you'll immediately hit three problems.
Problem 1: attributes silently disappear
$xml = simplexml_load_string('<request code="" typeReq="ABC"><cliente>Mario Rossi</cliente></request>');
echo json_encode($xml);
// {"cliente":"Mario Rossi"}
The code and typeReq attributes are just gone. SimpleXMLElement's default JSON serialization only looks at child elements, not attributes — you have to iterate $xml->attributes() yourself and merge them into the array under some convention (commonly a prefixed key like @typeReq), on every level of the tree, recursively.
Problem 2: single element vs. list is ambiguous — and it will shift under you
// One <Nota>
json_encode(simplexml_load_string('<Note><Nota>x</Nota></Note>'));
// {"Nota":"x"}
// Two <Nota>
json_encode(simplexml_load_string('<Note><Nota>x</Nota><Nota>y</Nota></Note>'));
// {"Nota":["x","y"]}
This is the classic XML-to-JSON trap: whether Nota comes back as a string/object or an array depends entirely on how many there happen to be in this particular document. Code written against a single-item response breaks the day the feed starts sending two. There's no way to tell PHP "this tag is conceptually always a list" — you'd have to special-case each affected key by hand, every time you write code that reads the result.
Problem 3: mixed content and CDATA aren't represented at all
A node like <operatore codArea="XXX">testo</operatore> — an attribute plus direct text, no child elements — doesn't map onto anything json_encode(simplexml) produces consistently, and CDATA sections vs. plain text end up handled inconsistently depending on how you access the node.
There's also a security angle worth being deliberate about: parsing XML from a source you don't fully trust without disabling external entity resolution opens the door to XXE attacks (a maliciously crafted <!DOCTYPE> reading local files or making outbound requests during parsing). simplexml_load_string() and a bare DOMDocument::loadXML() don't protect against this by default — you need to explicitly pass LIBXML_NONET (and historically libxml_disable_entity_loader(), though modern libxml disables external entities by default) if the XML isn't fully trusted.
A converter that solves all three
a35g/json-to-xml ships XmlToJsonConverter, the mirror image of the JSON→XML converter, using the same conventions in reverse:
composer require a35g/json-to-xml
use A35G\JsonToXml\XmlToJsonConverter;
$converter = new XmlToJsonConverter();
$array = $converter->xmlToArray(
'<request code="" typeReq="ABC"><cliente>Mario Rossi</cliente></request>'
);
// [
// '@code' => '',
// '@typeReq' => 'ABC',
// 'cliente' => 'Mario Rossi',
// ]
Attributes become @-prefixed keys at every level, automatically, no manual iteration.
Fixing the single-vs-list ambiguity
This is the part worth slowing down on, because it's the difference between a library that mirrors XML's own ambiguity and one that actually solves it. XmlToJsonConverter lets you name, up front, which tags must always come back as a list — regardless of how many elements are actually present in a given document:
$converter = new XmlToJsonConverter(forceArrayTags: ['Nota']);
$converter->xmlToArray('<Note><Nota><Principale>0</Principale></Nota></Note>');
// ['Nota' => [ ['Principale' => '0'] ]] <- still a list, even with one element
Without forceArrayTags, that same single <Nota> would come back as a plain object (['Nota' => ['Principale' => '0']]), and only turn into a list the day a second <Nota> shows up in the feed. With it, the shape of your data is stable from day one — your code can always do foreach ($data['Nota'] as $nota) without a "is it an array?" check first. You can set it at construction time or change it later with ->setForceArrayTags([...]).
Mixed content and CDATA
Text alongside attributes lands under the same #text convention used on the JSON→XML side:
$converter->xmlToArray('<operatore codArea="XXX"><![CDATA[XX]]></operatore>');
// ['operatore' => ['@codArea' => 'XXX', '#text' => 'XX']]
CDATA and plain text produce the same value — which value came from which representation isn't something the JSON side needs to know about.
Security by default
XmlToJsonConverter parses with LIBXML_NONET, so external entities can't trigger network requests or read local files during parsing, whether the XML comes from a trusted internal system or a form upload from the public internet.
Update (v1.3.0): the library now also rejects any reference to a custom entity declared in the DOCTYPE outright — both in attribute values and in element content — raising an
InvalidArgumentExceptioninstead of parsing it. This closes a quadratic-expansion denial-of-service vector thatLIBXML_NONETalone didn't cover: reading a maliciously crafted entity reference in an attribute value could previously expand it at quadratic cost, which neitherloadXML()nor libxml2's own default limits prevented. Predefined entities (&,<,>,",') and numeric character references (A) are unaffected — only custom entities declared via<!DOCTYPE ... [ <!ENTITY ...> ]>are rejected. If your XML relies on custom entities, inline their value before passing the document to the converter.
From file to file, in one call
$converter->xmlFileToJsonFile('input.xml', 'output.json');
One thing to know before you rely on round-tripping
Converting XML to JSON and back to XML is inherently lossy in a few specific, documented ways: a single element that later starts repeating changes shape unless you used forceArrayTags; CDATA-vs-plain-text information doesn't survive the round trip (both produce the same JSON string); and comments and processing instructions are dropped entirely. None of this is a bug — XML and JSON simply don't model the same things — but it's worth knowing about before you assume xmlToArray() followed by jsonToXmlString() gets you back exactly what you started with.
Takeaway
json_encode(simplexml_load_string(...)) gets you something JSON-shaped, but it silently drops attributes, changes shape based on how many elements happen to be present, and leaves XXE protection entirely up to you. If your XML has attributes, might repeat, or comes from outside your own system, it's worth reaching for a converter that was built to handle all three rather than patching them in as they bite you.
The next article puts both directions together and goes deep on the trickiest part of the whole format: attributes, automatic CDATA, and repeated elements — the three things that separate "converts flat JSON" from "converts real-world JSON."