One JSON shape for RSS, Atom and RDF feeds
How RSS to JSON turns three feed formats into one predictable array, and the namespace cases that took two attempts to get right.
A feed can be RSS 0.9x or 2.0, RSS 1.0 (which is RDF), or Atom, and any of them can carry extra namespaces such as Media RSS and Dublin Core. RSS to JSON reads any of them and gives back one array, ready for json_encode(). Fetching a feed from a URL you don’t trust is about getting the XML safely. This post is about the shape of what comes out, and the feeds that broke it.
Version 2.1.0, dated 18 October 2026 in the changelog, fixed two bugs in that shape. Fixing one of them broke a third kind of feed, which needed a second attempt.
A few fixed rules
The output follows a handful of rules, so the code that reads it doesn’t have to guess:
- the result is always
['channel' => [...]], Atom included - items are always a list under
channel['item'] - an element with only text becomes a string, and repeated elements become a list
- attributes go under
@attributes, and text next to attributes or child elements under@value - prefixed elements keep their prefix, such as
media:thumbnailordc:creator
Nearly all of it is one recursive method, parseElement() in src/RSSToJson.php. It collects every child into a list under its name, then unwraps the lists that hold only one entry:
// Flatten single-child arrays
foreach ($data as $key => $val) {
if (count($val) === 1 && array_key_exists(0, $val)) {
$data[$key] = $val[0];
}
}
// Add text content if element has no children
$text = trim((string)$element);
if (empty($data) && $text !== '') {
return $text;
} elseif ($text !== '') {
$data['@value'] = $text;
}
The array_key_exists(0, $val) check is what keeps @attributes intact. An element with one attribute has an @attributes map with one entry, but that entry is keyed by the attribute’s name, not 0, so it’s left as a map.
Where the items are
The three formats don’t agree on where the items live. RSS 2.0 puts <item> inside <channel>. RSS 1.0 puts its items beside the channel, directly under the root. Atom has no channel element at all, and calls its items <entry>. parseXml() looks in each place in turn:
// RSS 0.9x/2.0 keep items inside <channel>, RSS 1.0 (RDF) puts them
// beside it under the root, and Atom calls them <entry>.
$items = $channel->item ?? $xml->item ?? $xml->entry ?? [];
Before 2.1 that line had no $xml->item, so RSS 1.0 feeds came back with an empty item list.
The items are parsed separately and always written as a list, even when there’s only one. A feed with no items gets an empty list, so channel['item'] is always there. For Atom, the root <feed> element stands in for the channel. Its entries end up in two places: under entry, where the general parser put them, and under item. Read item. The entry key follows the general rule, so a feed with a single entry has an object there, not a list.
Namespaces, first attempt
Atom declares its namespace as the default one: <feed xmlns="http://www.w3.org/2005/Atom">. SimpleXML’s getNamespaces(true) lists that namespace with an empty prefix. The code looped over every namespace, read the children in each one, and stored them as prefix, colon, name. With an empty prefix that gave :title, and the ordinary pass over unprefixed children gave title as well. So every element of an Atom feed came out twice, as :title and title, :entry and entry. Any feed with a default namespace did the same, RSS 1.0 included, so its test checks for stray colons too. The changelog tells anyone who read :title to get round the duplicates to read title instead.
The first fix skipped the empty prefix, and matched prefixed children by their prefix rather than their namespace URI, so that each element would be read exactly once. Atom came out right, and RSS and Media RSS output didn’t change. Except for one kind of feed.
Second attempt
FeedBurner’s RSS 2.0 feeds bind the Atom namespace to two prefixes: atom: on the self link, and atom10:, declared on the element itself, on the hub link. The test fixture copies the pattern:
<atom:link href="https://example.com/feed.xml" rel="self" type="application/rss+xml"/>
<atom10:link xmlns:atom10="http://www.w3.org/2005/Atom" rel="hub" href="https://pubsubhubbub.appspot.com/"/>
Version 2.0 matched by URI, so atom:link and atom10:link each listed both links. Matching by prefix changed that: each key held only its own element. That’s arguably tidier, but it changed output that existing code may read, in a release meant to be backward compatible. Both attempts went into 2.1.0 before it was tagged, so no release ever had the first.
So the final version matches on the URI again, as 2.0 did, with one exception. A prefix bound to the same URI as the default namespace matches on the prefix, because matching it by URI would read every unprefixed element a second time:
$defaultUri = $namespaces[''] ?? null;
foreach ($namespaces as $prefix => $uri) {
$prefix = (string) $prefix;
if ($prefix === '') {
continue;
}
$children = $uri === $defaultUri ? $element->children($prefix, true) : $element->children($uri);
foreach ($children as $childName => $child) {
$qualifiedName = $prefix . ':' . $childName;
$data[$qualifiedName][] = $this->parseElement($child, $namespaces);
}
}
Each case has its own fixture and test. feedburner.xml checks that both keys still hold both links. atom-prefixed.xml declares the Atom namespace as the default and again as atom:, writes one element with the prefix, and the test checks that the keys come out exactly as atom:subtitle, title, id, entry and item.
Text, entities and CDATA
Plenty of feeds aren’t quite valid XML, so the text gets a clean-up before libxml sees it. A UTF-8 byte order mark and any whitespace before the XML declaration are removed, because the declaration has to come first. UTF-16 documents are left alone, since libxml reads their byte order mark to pick the encoding. Then any & that doesn’t start one of XML’s own entities is escaped, outside CDATA sections:
$escaped = preg_replace('/&(?!amp;|lt;|gt;|quot;|apos;|#[0-9]+;|#x[0-9a-fA-F]+;)/', '&', $text);
So “Fish & chips” parses, and an HTML entity XML doesn’t define, such as , comes back as the literal text rather than failing the whole feed. Numeric references such as © still decode. CDATA is loaded as plain text, and whatever is inside it stays literal: an & inside CDATA comes back as &, and a test checks it.
The odd cases it leaves alone
Some things follow from the rules and aren’t smoothed over. Only items are guaranteed to be a list. An item with one category has a string under category, and an item with two has a list, so code reading anything that can repeat has to handle both.
An empty element such as <title></title> comes back as an empty array, which json_encode() writes as [].
HTML that a feed forgets to escape or wrap in CDATA is XML as far as the parser can tell. A description of Hello <b>bold</b> world comes back as {"b": "bold", "@value": "Hello world"}, with the bold word moved out and two spaces where it was. Feeds are supposed to escape their HTML, and most do.
Using it
composer require tomgould/rsstojson
vendor/bin/rsstojson https://example.com/feed.xml
The command prints the feed as pretty-printed JSON, or the reason it failed. From PHP:
use TomGould\RSSToJson\RSSToJson;
$parser = new RSSToJson();
$feed = $parser->parseUrl('https://example.com/feed.xml');
if ($feed === null) {
echo $parser->getLastError(), PHP_EOL;
} else {
echo count($feed['channel']['item']), ' items', PHP_EOL;
}
parseFile() and parseString() do the same for a local file or a string, through the same clean-up. The code is on GitHub.