ceus-media / rss
PHP library to create, read and combine RSS 2.0 and Atom feeds, with podcast (iTunes) support.
Requires
- php: ^8.3
- ext-dom: *
- ext-libxml: *
- ext-mbstring: *
- ceus-media/common: ^1.1 | 1.1.x-dev
Requires (Dev)
- ceus-media/doc-creator: ^1.1 | 1.1.x-dev
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.35
- php-parallel-lint/php-parallel-lint: ^1.4
- phpstan/phpstan: ^2
- phpstan/phpstan-strict-rules: ^2
- phpunit/phpunit: ^10
- rector/rector: ^2
Suggests
- ext-curl: for reading feeds from URLs (UrlReader, Combiner::addUrl, Combiner::addUrls)
Provides
None
Conflicts
None
Replaces
None
README
PHP library to create, read and combine feeds: RSS 2.0 and Atom 1.0.
- Supports all elements of the RSS 2.0 specification.
- Reads Atom 1.0 feeds and maps them to the same models.
- Extensions for podcasts (
itunes:*), full content (content:encoded), authors (dc:creator) and feed links (atom:link) – and your own. - Typed models and immutable value objects, dates as
DateTimeImmutable. - Safe output: all values are escaped, characters not allowed in XML are removed.
- Lenient parsing: unknown extensions are ignored, invalid optional values are skipped.
- Secure, robust reading from URLs: TLS verification, timeout, size limit, HTTP(S) only, conditional requests (ETag, Last-Modified), parallel downloads.
- Combines several feeds into one: sorted by date, without duplicates, with source references and filters.
Upgrading from 0.4? Version 0.5 changes the API. See CHANGELOG.md.
Requirements
- PHP 8.3 or newer
- PHP extensions
dom,libxmlandmbstring - PHP extension
curl, only for reading feeds from URLs - ceus-media/common (installed automatically)
Installation
composer require ceus-media/rss
Quick start
use CeusMedia\RSS\Model\{Channel, Item}; use CeusMedia\RSS\Renderer; $channel = new Channel('Example News', 'https://example.com/', 'News of example.com'); $channel->addItem(new Item('Hello World', 'https://example.com/hello-world')); header('Content-Type: application/rss+xml; charset=utf-8'); echo Renderer::render($channel);
Creating a feed
A channel needs a title, a link and a description. Every item needs a title or a description.
use CeusMedia\RSS\Extension\Atom\ChannelData as AtomData; use CeusMedia\RSS\Model\{Category, Channel, Guid, Image, Item, Person}; use CeusMedia\RSS\Renderer; $channel = (new Channel('Example News', 'https://example.com/', 'News of example.com')) ->setLanguage('en-us') ->setManagingEditor(new Person('editor@example.com', 'Erika Editor')) ->addCategory('Technology') ->setTtl(60) // aggregators may cache for 60 minutes ->setImage((new Image('https://example.com/logo.png'))->setWidth(88)->setHeight(31)) ->setExtension(AtomData::self('https://example.com/feed.rss')); // link to the feed itself // an article: the GUID is the URL of the article, so it is a permalink (default) $channel->addItem((new Item('Hello World', 'https://example.com/hello-world')) ->setDescription('<p>HTML content is escaped automatically.</p>') ->setAuthor(new Person('anna@example.com', 'Anna Author')) ->addCategory(new Category('news/featured', 'https://example.com/categories')) ->setPubDate(new DateTimeImmutable('2024-03-06 17:00:00+01:00')) ->setGuid(new Guid('https://example.com/hello-world'))); $xml = Renderer::render($channel); // indented with 2 spaces $xml = Renderer::render($channel, TRUE); // indented with tabs
If the image has no title or link, the renderer uses the title and link of the channel.
If no lastBuildDate is set, the current time is used.
Podcasts
The iTunes extension adds the elements required by Apple Podcasts, Spotify and other directories.
use CeusMedia\RSS\Extension\ITunes\{Category, ChannelData as PodcastData, ItemData as EpisodeData}; use CeusMedia\RSS\Model\{Enclosure, Guid, Item, Person}; $channel->setExtension((new PodcastData()) ->setAuthor('Hanna Host') ->setOwner(new Person('host@example.com', 'Hanna Host')) ->setImage('https://example.com/artwork-3000.jpg') ->addCategory(new Category('Technology')) ->setExplicit(FALSE) ->setType('episodic')); $channel->addItem((new Item('Episode 1: What is RSS?', 'https://example.com/episodes/1')) ->setEnclosure(new Enclosure('https://example.com/episode-1.mp3', 28960000, 'audio/mpeg')) ->setGuid(new Guid('episode-1', isPermaLink: FALSE)) ->setExtension((new EpisodeData())->setEpisode(1)->setDuration(1810)));
Reading a feed
FeedParser detects the format and parses RSS 2.0 and Atom 1.0 into the same models.
use CeusMedia\RSS\{FeedParser, UrlReader}; use CeusMedia\RSS\Extension\Content\ItemData as ContentData; $channel = FeedParser::parse(file_get_contents('feed.xml')); $channel = FeedParser::parse(UrlReader::read('https://example.org/feed.rss')); foreach ($channel->getItems() as $item) { echo $item->getPubDate()?->format('Y-m-d').' '.$item->getTitle().PHP_EOL; $fullContent = $item->getExtension(ContentData::class)?->encoded; }
Use Parser or AtomParser directly to accept only one format.
Parsing is lenient, so feeds from the wild can be read:
- Elements of unknown extensions are ignored.
- Invalid optional values are skipped, for example malformed dates or out-of-range image sizes.
- Dates are accepted in RFC 822 format and common deviations (e.g.
GMT, ISO 8601). - Leading and trailing whitespace of values is removed.
Atom feeds are mapped like this: subtitle → description, updated → lastBuildDate,
summary → description, content → content:encoded, id → guid, published → pubDate,
authors with email → author, author names → dc:creator, enclosure links → enclosure.
Reading from URLs
UrlReader uses cURL with secure settings:
| Setting | Default |
|---|---|
| Protocols | HTTP and HTTPS only, also for redirects |
| TLS | certificate and host name are verified |
| Timeout | 10 seconds, connect 5 seconds |
| Size limit | 10 MiB |
| Redirects | at most 5 |
| Compression | gzip, deflate (and brotli, if supported by cURL) |
| User agent | CeusMedia-RSS/0.5 (+https://github.com/CeusMedia/RSS) |
use CeusMedia\RSS\{FeedParser, UrlReader}; use CeusMedia\RSS\Exception\ReadException; $reader = new UrlReader(timeout: 5, maxSize: 2_097_152); // conditional request: unchanged feeds are not downloaded again $response = $reader->fetch($url, $previousEtag, $previousLastModified); if (!$response->isNotModified()) { $channel = FeedParser::parse($response->content); // store $response->etag and $response->lastModified for the next request } // several feeds in parallel, failures do not stop other requests foreach ($reader->fetchAll(['news' => $url1, 'blog' => $url2]) as $key => $result) { if ($result instanceof ReadException) echo $key.': '.$result->getMessage().PHP_EOL; }
Combining feeds
use CeusMedia\RSS\{Combiner, Filter}; use CeusMedia\RSS\Model\{Channel, Item}; $combiner = (new Combiner()) ->add($rssOrAtomXml, 'https://example.org/feed.rss') // URL is used for <source> of items ->addChannel($channel); // read feeds in parallel, failing feeds are skipped and reported $errors = $combiner->addUrls(['https://example.org/feed.rss', 'https://example.com/feed.atom']); $combiner ->addFilter(Filter::category('Releases')) ->addFilter(Filter::since(new DateTimeImmutable('-30 days'))) ->addFilter(fn(Item $item, Channel $channel): bool => NULL !== $item->getLink()); $items = $combiner->combine(10); // newest 10 items of all feeds $target = new Channel('Planet Example', 'https://planet.example.org/', 'All feeds in one'); $xml = $combiner->render($target, 10); // as new feed, $target is not modified
- Items are sorted by publication date, newest first, regardless of their timezone. Items without date are placed last.
- Duplicates (same GUID, or same link if there is no GUID) are removed, see
setUnique(). - Items get a
<source>referencing their original feed, if its URL is known, seesetAddSource(). - Predefined filters:
Filter::category(),Filter::search(),Filter::since(),Filter::not().
Extensions
| Extension | Prefix | Data classes | Elements |
|---|---|---|---|
| Atom | atom |
Extension\Atom\ChannelData, Link |
atom:link (e.g. rel="self", WebSub rel="hub") |
| Content | content |
Extension\Content\ItemData |
content:encoded |
| Dublin Core | dc |
Extension\DublinCore\Data |
dc:creator |
| iTunes | itunes |
Extension\ITunes\ChannelData, ItemData, Category |
itunes:* for podcasts |
Extension data is stored in channels and items:
$channel->setExtension($data); $data = $channel->getExtension(PodcastData::class); // typed, NULL if not set
Namespaces are only declared in the output if the feed uses them.
Parser and renderer use all built-in extensions by default. To use your own extension,
extend Extension\AbstractExtension and register it:
use CeusMedia\RSS\Extension\Extensions; $extensions = Extensions::default()->add(new MyExtension()); $channel = FeedParser::parse($xml, $extensions); $xml = Renderer::render($channel, FALSE, $extensions);
Models
| Class | Type | Content |
|---|---|---|
Model\Channel |
model (mutable) | channel data, items and extension data |
Model\Item |
model (mutable) | item data and extension data |
Model\Image |
model (mutable) | channel image, width 1–144, height 1–400 |
Model\Person |
value object | email and optional name of author, managing editor, web master |
Model\Guid |
value object | unique identifier of item, permalink by default |
Model\Category |
value object | category name and optional domain |
Model\Enclosure |
value object | media file: URL, length in bytes, MIME type |
Model\Source |
value object | title and URL of the original feed of an item |
Model\Cloud |
value object | rssCloud settings |
Model\TextInput |
value object | text input box of channel |
Response |
value object | response of UrlReader: status, content, ETag, Last-Modified |
Value objects are immutable (readonly) and validate their values on creation.
Supported elements
- RSS 2.0 channel:
title,link,description,language,copyright,managingEditor,webMaster,pubDate,lastBuildDate,category,generator,docs,cloud,ttl,image,rating,textInput,skipHours,skipDays - RSS 2.0 item:
title,link,description,author,category,comments,enclosure,guid,pubDate,source - Atom 1.0 (reading):
feedandentrywith text constructs (text, html, xhtml), links, persons and categories - Extensions: see above
Not supported: RSS 0.9x and 1.0 (RDF), writing Atom.
Error handling
| Where | Exception | Thrown if |
|---|---|---|
UrlReader, Combiner::addUrl() |
CeusMedia\RSS\Exception\ReadException |
URL is invalid, request failed or timed out, response too large |
FeedParser, Parser, AtomParser, Combiner::add() |
CeusMedia\RSS\Exception\ParseException |
input is empty, not well-formed XML or not a supported feed |
Renderer::render(), Combiner::render() |
CeusMedia\RSS\Exception\RenderException |
channel title, link or description is empty, an item has neither title nor description |
| models and value objects | InvalidArgumentException |
a value is invalid, e.g. image width > 144, negative TTL, empty GUID |
ReadException, ParseException and RenderException extend RuntimeException.
ReadException has the HTTP status or cURL error number as code.
use CeusMedia\RSS\{FeedParser, UrlReader}; use CeusMedia\RSS\Exception\{ParseException, ReadException}; try { $channel = FeedParser::parse(UrlReader::read($url)); } catch (ReadException $e) { // network problem or HTTP error } catch (ParseException $e) { // not a valid feed }
Demos
The folder demo/ contains runnable examples:
| Script | Content |
|---|---|
demo/create.php |
Create a feed with image, categories, enclosure and more |
demo/read.php |
Read an RSS or Atom feed from a file or URL: php demo/read.php https://example.org/feed.rss |
demo/combine.php |
Combine RSS and Atom feeds, newest items first, without duplicates |
demo/errors.php |
Error handling of parser, renderer and models |
demo/podcast.php |
Podcast feed for Apple Podcasts and Spotify |
demo/planet.php |
Planet: static HTML page and feed combining several feeds (--feed for the feed) |
demo/notify.php |
Notifications for new entries as Slack webhook payloads (--reset to start over) |
demo/filter.php |
Filtered feed: only recent releases, or entries containing a search text |
Run them on the command line, for example php demo/podcast.php,
or in the browser with composer dev:demo and open http://localhost:8080/.
Development
composer install
composer dev:test-all # syntax check, coding style, PHPStan and unit tests
composer dev:test-units # unit tests only
composer dev:test-coverage # unit tests with code coverage (needs Xdebug or PCOV)
composer dev:infection # mutation tests (needs Xdebug or PCOV)
composer dev:cs # check coding style, dev:cs-fix to fix it
composer dev:stan # PHPStan on level max with strict rules
composer dev:rector # show Rector suggestions for PHP 8.3
composer dev:demo # web server for demos on http://localhost:8080/
composer dev:docs # documentation (MkDocs) in doc/Site, see below
composer dev:create-docs # classic API documentation by DocCreator in doc/API
The documentation – guide and API reference – is built with MkDocs and Material for MkDocs:
composer dev:docs # generate API reference and build site, open doc/Site/index.html
composer dev:docs-serve # live preview on http://localhost:8000
composer dev:docs-install # install MkDocs into a Python venv (optional, else Docker is used)
Hand-written pages are in doc/Manual/, the API reference is generated from the doc blocks.
Reports are written to doc/Test/: coverage in doc/Test/Coverage/index.html,
mutation tests in doc/Test/Infection/index.html.
Tests of UrlReader start local PHP web servers, no internet connection is needed.
GitHub Actions runs all checks on PHP 8.3, 8.4 and 8.5 and builds the documentation, see .github/workflows/ci.yml.
| Folder | Content |
|---|---|
src/ |
library classes |
src/Extension/ |
extensions for namespaced elements |
test/ |
PHPUnit tests and fixtures |
demo/ |
runnable examples and sample feeds |
doc/Manual/ |
documentation: guide (hand-written) and API reference (generated) |
tool/ |
configuration of PHPUnit, PHPStan, PHP-CS-Fixer, Infection, Rector, MkDocs and DocCreator |
License
GPL-3.0-or-later, see gnu.org/licenses/gpl-3.0. Copyright © 2012–2026 Christian Würker, Ceus Media.