---
title: 'One JSON shape for RSS, Atom and RDF feeds'
canonical: 'https://www.dealmobile.co.uk/writing/one-json-shape-for-rss-atom-and-rdf'
url: 'https://www.dealmobile.co.uk/writing/one-json-shape-for-rss-atom-and-rdf'
type: Post
published: '2026-10-21'
summary: 'How RSS to JSON turns three feed formats into one predictable array, and the namespace cases that took two attempts to get right.'
---

# One JSON shape for RSS, Atom and RDF feeds

Published 2026-10-21

![Three boxes, RSS 2.0, RSS 1.0 and Atom, each with arrows pointing to one highlighted box labelled channel\[‘item’\], always a list.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot_md/public/content/images/posts/one-json-shape-for-rss-atom-and-rdf/hero.png.webp?itok=f93lpuPk)

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](https://www.dealmobile.co.uk/projects/rss-to-json.md) reads any of them and gives back one array, ready for `json_encode()`. [Fetching a feed from a URL you don’t trust](https://www.dealmobile.co.uk/writing/fetching-feeds-from-untrusted-urls.md) 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:thumbnail` or `dc: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:

```php
// 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:

```php
// 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:

```xml
<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:

```php
$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:

```php
$escaped = preg_replace('/&(?!amp;|lt;|gt;|quot;|apos;|#[0-9]+;|#x[0-9a-fA-F]+;)/', '&amp;', $text);

```

So “Fish &amp; chips” parses, and an HTML entity XML doesn’t define, such as `&nbsp;`, comes back as the literal text `&nbsp;` rather than failing the whole feed. Numeric references such as `&#169;` still decode. CDATA is loaded as plain text, and whatever is inside it stays literal: an `&amp;` inside CDATA comes back as `&amp;`, 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

```bash
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:

```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](https://github.com/tomgould/rss-to-json).

- **Projects:** [RSS to JSON](https://www.dealmobile.co.uk/projects/rss-to-json.md)
- **Tags:** [PHP](https://www.dealmobile.co.uk/writing/tag/php.md), [Data](https://www.dealmobile.co.uk/writing/tag/data.md), [Testing](https://www.dealmobile.co.uk/writing/tag/testing.md), [RSS to JSON](https://www.dealmobile.co.uk/writing/tag/rss-json.md)

---

Index of this site: [llms.txt](https://www.dealmobile.co.uk/llms.txt)
