PHP Packagev8.3.0MIT License

PHP RSS

PHP RSS is a lightweight RSS 2.0 feed generator for PHP 8. You describe the channel and its items with a fluent Feed and Item API, and toXml() returns a well-formed, specification-ordered XML document. It has no Composer dependencies beyond ext-xmlwriter, validates everything before writing, and supports the Atom, Content, Dublin Core, and Media RSS namespaces out of the box.

Fluent Feed Builder

Every RSS 2.0 channel and item element is one chained method on or .

Always Well-Formed

Invalid UTF-8, XML control characters, and in CDATA are rejected or handled, never written as broken XML.

Namespaced Extensions

Built-in , , , and Media RSS elements, with namespaces declared once on the root.

Your Own Extensions

Implement one small contract to add any XML namespace, such as iTunes or GeoRSS.

Podcasts and Media

Enclosures, , and thumbnails cover podcast and video feeds.

Clear Exceptions

Every error extends and tells you exactly which field is wrong.

Quick Tip

toXml() only returns a string. It never sends HTTP headers, so you stay in control of the Content-Type and caching headers your application sends.

Installation

Install the package with Composer. There is no configuration file to publish and nothing to register.

Requirements

  • PHP 8.3
  • The xmlwriter extension (ext-xmlwriter), bundled with most PHP builds

You can check that the extension is enabled with:

Terminal
php -m | grep -i xmlwriter

Composer Installation

Terminal
composer require pharaonic/php-rss

Namespace

Every class lives under the Pharaonic\RSS namespace:

NamespaceContains
Pharaonic\RSSFeed, Item
Pharaonic\RSS\ElementsCategory, Guid, Enclosure, Image, Source, Cloud, TextInput
Pharaonic\RSS\Extensions\*Atom, Content, Dublin Core, and Media RSS elements
Pharaonic\RSS\ContractsExtension
Pharaonic\RSS\SupportDay, CloudProtocol, DateFormatter, Xml, NamespaceRegistry
Pharaonic\RSS\ExceptionsRssException and its subclasses
Pharaonic\RSS\WriterRssWriter
Installation Complete

You're all set! Head to Basic Usage to build your first feed.

Basic Usage

A feed is a Feed (the RSS <channel>) holding any number of Item objects. Both are created with make() and configured with chained setters.

Your First Feed

A channel needs a title, a link, and a description. Each item needs at least a title or a description.

public/rss.php
1use Pharaonic\RSS\Feed;
2use Pharaonic\RSS\Item;
3 
4$cairo = new DateTimeZone('Africa/Cairo');
5 
6$feed = Feed::make()
7 ->title('Pharaonic Blog')
8 ->link('https://pharaonic.dev/blog')
9 ->description('News & releases from the Pharaonic team.')
10 ->language('en-US')
11 ->lastBuildAt(new DateTimeImmutable('2026-10-06 12:00:00', $cairo));
12 
13$feed->addItem(
14 Item::make()
15 ->title('PHP RSS rebuilt')
16 ->link('https://pharaonic.dev/blog/php-rss-rebuilt')
17 ->description('<p>A modern <strong>RSS 2.0</strong> generator for PHP.</p>')
18 ->category('PHP')
19 ->guid('https://pharaonic.dev/blog/php-rss-rebuilt')
20 ->publishedAt(new DateTimeImmutable('2026-10-06 10:00:00', $cairo))
21);
22 
23header('Content-Type: application/rss+xml; charset=UTF-8');
24echo $feed->toXml();

The output looks like this:

Output
1<?xml version="1.0" encoding="UTF-8"?>
2<rss version="2.0">
3 <channel>
4 <title>Pharaonic Blog</title>
5 <link>https://pharaonic.dev/blog</link>
6 <description>News &amp; releases from the Pharaonic team.</description>
7 <language>en-US</language>
8 <lastBuildDate>Tue, 06 Oct 2026 12:00:00 +0300</lastBuildDate>
9 <item>
10 <title>PHP RSS rebuilt</title>
11 <link>https://pharaonic.dev/blog/php-rss-rebuilt</link>
12 <description>&lt;p&gt;A modern &lt;strong&gt;RSS 2.0&lt;/strong&gt; generator for PHP.&lt;/p&gt;</description>
13 <category>PHP</category>
14 <guid>https://pharaonic.dev/blog/php-rss-rebuilt</guid>
15 <pubDate>Tue, 06 Oct 2026 10:00:00 +0300</pubDate>
16 </item>
17 </channel>
18</rss>

Adding Many Items

addItems() accepts any iterable of Item objects, so you can map your records directly:

1$feed->addItems(array_map(
2 fn (array $post) => Item::make()
3 ->title($post['title'])
4 ->link($post['url'])
5 ->guid($post['url'])
6 ->publishedAt(new DateTimeImmutable($post['published_at'])),
7 $posts
8));

Items are written in the order you add them. Sort your records (usually newest first) before adding them.

Pretty or Compact Output

toXml() indents with two spaces by default. Pass false for a compact document:

1$feed->toXml(); // indented, easy to read
2$feed->toXml(false); // compact, smallest size

Rules Worth Knowing

  • Empty values are omitted. Passing null or '' to an optional setter removes the element from the output. Values such as "0" are kept.
  • Order doesn't matter. Required fields are validated when you call toXml(), so you can call setters in any order.
  • Escaping is automatic. Text is XML-escaped for you. HTML in description() is escaped, which is how RSS 2.0 carries HTML. Use content:encoded to send it as CDATA instead.
  • Dates keep their timezone. Any DateTimeInterface is formatted as RFC 822 using the offset of the date you pass, never the server timezone.
  • No HTTP side effects. The package never calls header() or echo.
Content-Type

Send application/rss+xml; charset=UTF-8 (or application/xml) yourself. Browsers and readers may not recognize the feed if it's served as text/html.

Channel

Feed represents the RSS <channel>. It covers every channel element in RSS 2.0, and the writer outputs them in specification order whatever order you call the setters in.

Required Elements

1use Pharaonic\RSS\Feed;
2 
3$feed = Feed::make()
4 ->title('Pharaonic') // <title>
5 ->link('https://pharaonic.dev') // <link>
6 ->description('Rooted in History. Engineering the Future.'); // <description>

toXml() throws an InvalidFeedException if any of the three is missing or blank.

Text Elements

1$feed
2 ->language('ar-EG')
3 ->copyright('© Pharaonic')
4 ->managingEditor('[email protected] (Editorial Team)')
5 ->webMaster('[email protected] (Pharaonic Ops)')
6 ->generator('My CMS')
7 ->docs('https://www.rssboard.org/rss-specification')
8 ->rating('(PICS-1.1 "http://www.rsac.org/ratingsv01.html" l by "[email protected]" on "2026.10.06T10:00-0500" r (n 0 s 0 v 0 l 0))');
No implicit values

The package doesn't add a <generator> or a default <pubDate>. Only what you set is written.

Dates

publishedAt() writes <pubDate> and lastBuildAt() writes <lastBuildDate>. Both accept any DateTimeInterface and keep its timezone offset:

1$feed
2 ->publishedAt(new DateTimeImmutable('2026-10-06 10:00:00', new DateTimeZone('UTC')))
3 ->lastBuildAt(new DateTime('2026-10-06 13:00:00', new DateTimeZone('Africa/Cairo')));
4 
5// <pubDate>Tue, 06 Oct 2026 10:00:00 +0000</pubDate>
6// <lastBuildDate>Tue, 06 Oct 2026 13:00:00 +0300</lastBuildDate>

Mutable DateTime objects are copied, so changing them afterwards doesn't change the feed. Pass null to remove a date.

Categories

category() adds a category each time you call it. Pass a string, or a Category with a domain:

1use Pharaonic\RSS\Elements\Category;
2 
3$feed
4 ->category('Technology')
5 ->category(Category::make('Software/PHP')->domain('https://pharaonic.dev/topics'));

Image

The channel image is an Image object. When you don't set its title or link, the writer uses the channel title and link, as RSS 2.0 recommends.

1use Pharaonic\RSS\Elements\Image;
2 
3$feed->image(
4 Image::make('https://pharaonic.dev/logo.png')
5 ->width(144) // 1 to 144 (Image::MAX_WIDTH)
6 ->height(144) // 1 to 400 (Image::MAX_HEIGHT)
7 ->description('Pharaonic logo')
8);

Width and height are only written when set. Readers assume 88×31 when they're missing.

Caching Hints

1use Pharaonic\RSS\Support\Day;
2 
3$feed
4 ->ttl(60) // readers may cache for 60 minutes
5 ->skipHour(0) // 0-23, GMT
6 ->skipHour(1)
7 ->skipDay(Day::SATURDAY)
8 ->skipDay(Day::SUNDAY);

Duplicate hours and days are ignored. A negative ttl, an hour outside 0–23, or an unknown day name throws an InvalidFeedException.

Cloud and Text Input

These two elements are rarely used, but both are supported:

1use Pharaonic\RSS\Elements\Cloud;
2use Pharaonic\RSS\Elements\TextInput;
3use Pharaonic\RSS\Support\CloudProtocol;
4 
5$feed
6 ->cloud(Cloud::make('rpc.pharaonic.dev', 443, '/rpc', 'pleaseNotify', CloudProtocol::XML_RPC))
7 ->textInput(TextInput::make('Search', 'Search the blog', 'q', 'https://pharaonic.dev/search'));

CloudProtocol offers XML_RPC, SOAP, and HTTP_POST. The register procedure may be an empty string with HTTP_POST.

Channel Extensions

Namespaced elements such as an Atom self link are attached with extension(). See Extensions.

1use Pharaonic\RSS\Extensions\Atom\Link;
2 
3$feed->extension(Link::self('https://pharaonic.dev/rss.xml'));

Items

Item represents an RSS <item>. Every element is optional, but an item needs at least a title or a description.

A Complete Item

1use Pharaonic\RSS\Elements\Enclosure;
2use Pharaonic\RSS\Elements\Source;
3use Pharaonic\RSS\Item;
4 
5$item = Item::make()
6 ->title('PHP RSS rebuilt')
7 ->link('https://pharaonic.dev/blog/php-rss-rebuilt')
8 ->description('<p>A modern <strong>RSS 2.0</strong> generator for PHP.</p>')
9 ->author('[email protected] (Moamen Eltouny)')
10 ->category('PHP')
11 ->category('RSS')
12 ->comments('https://pharaonic.dev/blog/php-rss-rebuilt#comments')
13 ->enclosure(Enclosure::make('https://cdn.pharaonic.dev/cover.jpg', 48213, 'image/jpeg'))
14 ->guid('https://pharaonic.dev/blog/php-rss-rebuilt')
15 ->publishedAt(new DateTimeImmutable('2026-10-06 10:00:00'))
16 ->source(Source::make('Pharaonic News', 'https://pharaonic.dev/news/rss.xml'));
17 
18$feed->addItem($item);

Description and HTML

description() accepts HTML and escapes it in the output, which RSS readers decode and render. To ship the full article body as CDATA, add a content:encoded extension alongside a short description.

Author

RSS 2.0 expects an email address in <author>, conventionally email (Name). If you only have a name, use the Dublin Core Creator extension instead:

1use Pharaonic\RSS\Extensions\DublinCore\Creator;
2 
3$item->extension(Creator::make('Moamen Eltouny')); // <dc:creator>Moamen Eltouny</dc:creator>

GUIDs

A GUID uniquely identifies an item, so readers don't show it twice. RSS 2.0 treats a GUID as a permalink unless isPermaLink="false" is set.

1$item->guid('https://pharaonic.dev/blog/42'); // <guid>https://pharaonic.dev/blog/42</guid>
2$item->guid('post-42', false); // <guid isPermaLink="false">post-42</guid>

You can also pass a Guid object:

1use Pharaonic\RSS\Elements\Guid;
2 
3$item->guid(Guid::make('post-42')->permalink(false));
Guid object and flag

Passing a Guid object together with the second argument throws an InvalidItemException. Set the flag on the object with permalink() instead.

Enclosures

An enclosure attaches a media file. It needs the URL, the size in bytes, and the MIME type:

1$item->enclosure(Enclosure::make('https://cdn.pharaonic.dev/podcast/1.mp3', 24986239, 'audio/mpeg'));

RSS 2.0 allows one enclosure per item. Use Media RSS when you need several media objects.

Source

source() credits the channel an item was republished from:

1$item->source(Source::make('Pharaonic News', 'https://pharaonic.dev/news/rss.xml'));
2// <source url="https://pharaonic.dev/news/rss.xml">Pharaonic News</source>

Item Extensions

Namespaced elements are added with extension() and written after the core item elements, in the order you add them:

1use Pharaonic\RSS\Extensions\Content\Encoded;
2 
3$item
4 ->extension(Creator::make('Moamen Eltouny'))
5 ->extension(Encoded::make('<article><h1>PHP RSS</h1><p>Full body…</p></article>'));

Extensions

RSS 2.0 is extended with XML namespaces. The package ships four of the most common ones. Attach them to the feed or to an item with extension().

Before writing, the writer collects the namespace of every extension in the feed and its items, then declares each one once on the <rss> root. Only the namespaces you actually use are declared.

ExtensionNamespace prefixClasses
AtomatomPharaonic\RSS\Extensions\Atom\Link
ContentcontentPharaonic\RSS\Extensions\Content\Encoded
Dublin CoredcPharaonic\RSS\Extensions\DublinCore\Creator
Media RSSmediaPharaonic\RSS\Extensions\Media\Content, Thumbnail, Title, Description

Atom Self Link

Feed validators recommend that a feed links to its own URL. Link::self() sets rel="self" and type="application/rss+xml" for you:

1use Pharaonic\RSS\Extensions\Atom\Link;
2 
3$feed->extension(Link::self('https://pharaonic.dev/rss.xml'));
4// <atom:link href="https://pharaonic.dev/rss.xml" rel="self" type="application/rss+xml"/>

Link::make() builds any other Atom link, for example a WebSub hub:

1$feed->extension(Link::make('https://pubsubhubbub.appspot.com/')->rel('hub'));

Link also has type(), hreflang(), title(), and length().

Full Content (content:encoded)

Encoded carries the full HTML body of an item as CDATA, without escaping. A ]]> inside the content is split safely across CDATA sections, so the document always stays valid.

1use Pharaonic\RSS\Extensions\Content\Encoded;
2 
3$item
4 ->description('A short summary for list views.')
5 ->extension(Encoded::make($post->html));

Creator (dc:creator)

Unlike <author>, dc:creator accepts a plain name:

1use Pharaonic\RSS\Extensions\DublinCore\Creator;
2 
3$item->extension(Creator::make('Moamen Eltouny'));

Media RSS

Content describes a media object, with optional nested title, description, and thumbnails:

1use Pharaonic\RSS\Extensions\Media\Content;
2use Pharaonic\RSS\Extensions\Media\Description;
3use Pharaonic\RSS\Extensions\Media\Thumbnail;
4use Pharaonic\RSS\Extensions\Media\Title;
5 
6$item->extension(
7 Content::make('https://cdn.pharaonic.dev/giza.mp4')
8 ->type('video/mp4')
9 ->medium(Content::MEDIUM_VIDEO)
10 ->expression(Content::EXPRESSION_FULL)
11 ->fileSize(52428800)
12 ->duration(95)
13 ->width(1920)
14 ->height(1080)
15 ->lang('en')
16 ->isDefault()
17 ->title(Title::make('Giza pyramids'))
18 ->description(Description::make('<b>Drone</b> footage')->type(Description::TYPE_HTML))
19 ->thumbnail(Thumbnail::make('https://cdn.pharaonic.dev/giza.jpg')->width(1200)->height(630))
20);

A Thumbnail can also be attached directly to an item, which many readers use as the item's preview image:

1$item->extension(Thumbnail::make('https://cdn.pharaonic.dev/note.jpg'));
ConstantValues
Content::MEDIUM_*IMAGE, AUDIO, VIDEO, DOCUMENT, EXECUTABLE
Content::EXPRESSION_*SAMPLE, FULL, NONSTOP
Title::TYPE_* / Description::TYPE_*PLAIN, HTML

An unknown medium, expression, or text type throws an InvalidElementException.

Order of extensions

Extensions are written after the core elements of their parent (channel or item), in the order you added them.

Custom Extensions

Any namespace the package doesn't ship (iTunes, GeoRSS, Slash, your own) can be added by implementing Pharaonic\RSS\Contracts\Extension.

The Contract

1interface Extension
2{
3 public function prefix(): string; // e.g. "geo"
4 public function namespaceUri(): string; // e.g. "http://www.w3.org/2003/01/geo/wgs84_pos#"
5 public function write(XMLWriter $writer): void;
6}

write() receives the XMLWriter positioned inside the parent element. Write prefixed element names such as geo:lat, and don't declare the namespace yourself, because the writer declares it on the root.

Writing Values Safely

XMLWriter copies invalid UTF-8 and XML control characters into the output as they are. Use the helpers in Pharaonic\RSS\Support\Xml to keep the document well-formed:

HelperWrites
Xml::writeElement($writer, $name, $value)An element with escaped text
Xml::writeText($writer, $value, $context)Escaped text inside the open element
Xml::writeAttribute($writer, $name, $value)An escaped attribute on the open element
Xml::writeCdata($writer, $content)CDATA, splitting any ]]> safely

Each one throws an InvalidElementException instead of writing an invalid value.

Example: GeoRSS Point

src/Rss/GeoPoint.php
1namespace App\Rss;
2 
3use Pharaonic\RSS\Contracts\Extension;
4use Pharaonic\RSS\Support\Xml;
5use XMLWriter;
6 
7final class GeoPoint implements Extension
8{
9 public function __construct(private float $latitude, private float $longitude)
10 {
11 }
12 
13 public function prefix(): string
14 {
15 return 'geo';
16 }
17 
18 public function namespaceUri(): string
19 {
20 return 'http://www.w3.org/2003/01/geo/wgs84_pos#';
21 }
22 
23 public function write(XMLWriter $writer): void
24 {
25 Xml::writeElement($writer, 'geo:lat', (string) $this->latitude);
26 Xml::writeElement($writer, 'geo:long', (string) $this->longitude);
27 }
28}

Attach it like any built-in extension:

1$item->extension(new GeoPoint(29.9792, 31.1342));
2// <geo:lat>29.9792</geo:lat>
3// <geo:long>31.1342</geo:long>

Example: iTunes Podcast Tags

One extension class can write several elements. This one writes any set of iTunes tags:

src/Rss/Itunes.php
1namespace App\Rss;
2 
3use Pharaonic\RSS\Contracts\Extension;
4use Pharaonic\RSS\Support\Xml;
5use XMLWriter;
6 
7final class Itunes implements Extension
8{
9 /** @param array<string, string> $tags e.g. ['author' => 'Pharaonic', 'explicit' => 'false'] */
10 public function __construct(private array $tags)
11 {
12 }
13 
14 public function prefix(): string
15 {
16 return 'itunes';
17 }
18 
19 public function namespaceUri(): string
20 {
21 return 'http://www.itunes.com/dtds/podcast-1.0.dtd';
22 }
23 
24 public function write(XMLWriter $writer): void
25 {
26 foreach ($this->tags as $name => $value) {
27 Xml::writeElement($writer, 'itunes:' . $name, $value);
28 }
29 }
30}

Namespace Rules

The writer validates namespaces with NamespaceRegistry before it writes anything:

  • The prefix must be a valid XML name and can't be xml or xmlns.
  • The URI can't be empty.
  • One prefix can't be bound to two different URIs in the same feed. Registering the same prefix and URI twice is fine.

Breaking a rule throws an InvalidElementException.

Validation and Errors

The package refuses to produce an invalid document. Value objects validate their input as soon as you create them, and toXml() validates the feed as a whole before it writes anything.

Exception Hierarchy

Every exception extends Pharaonic\RSS\Exceptions\RssException, which extends RuntimeException, so one catch covers them all.

ExceptionThrown when
InvalidFeedExceptionThe channel is missing a title, link, or description, or gets a negative ttl, an hour outside 0–23, or an unknown skip day
InvalidItemExceptionAn item has neither a title nor a description, or guid() gets a Guid object together with a permalink flag
InvalidElementExceptionAn element or extension gets an empty required value, an out-of-range number, an unknown choice, an invalid namespace, or text that isn't valid XML

When Validation Runs

CheckRuns at
Empty URL, value, or name in an element or extensionConstruction (make() / new)
Numeric ranges, choices (medium, protocol, width…)The setter call
Channel title, link, descriptiontoXml()
Item title-or-descriptiontoXml()
Namespace prefixes and conflictstoXml()
Invalid UTF-8 and XML control characterstoXml()

Catching Errors

1use Pharaonic\RSS\Exceptions\RssException;
2 
3try {
4 $xml = $feed->toXml();
5} catch (RssException $e) {
6 error_log($e->getMessage());
7 // "The RSS item at position 3 requires at least a title or a description."
8}

Messages name the element, the field, and the value received, for example:

Messages
1The RSS channel requires a non-empty title. Call Feed::title() before serializing.
2The image width must be between 1 and 144, 300 given.
3The cloud protocol must be one of [xml-rpc, soap, http-post], "https" given.
4The value of <title> contains invalid UTF-8 or characters that are not allowed in XML 1.0.
5The XML namespace prefix "media" is already bound to "http://search.yahoo.com/mrss/" and cannot also be bound to "https://example.com/media".
Untrusted text

Content copied from user input, legacy databases, or Word documents often contains control characters (such as \x0B) or broken UTF-8. Clean it before adding it to the feed, for example with mb_convert_encoding($text, 'UTF-8', 'UTF-8') and preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $text).

Feed API

Pharaonic\RSS\Feed is a final class. Every setter returns the same Feed instance for chaining. Optional string setters treat null and '' as "not set".

Setters

MethodElementNotes
Feed::make()Creates an empty feed
title(?string $title)<title>Required
link(?string $link)<link>Required
description(?string $description)<description>Required
language(?string $language)<language>e.g. en-US, ar-EG
copyright(?string $copyright)<copyright>
managingEditor(?string $managingEditor)<managingEditor>email (Name)
webMaster(?string $webMaster)<webMaster>email (Name)
publishedAt(?DateTimeInterface $date)<pubDate>RFC 822, keeps the offset
lastBuildAt(?DateTimeInterface $date)<lastBuildDate>RFC 822, keeps the offset
category(Category|string $category)<category>Adds one per call
generator(?string $generator)<generator>Not set by default
docs(?string $docs)<docs>
cloud(?Cloud $cloud)<cloud>
ttl(?int $minutes)<ttl>Throws when negative
image(?Image $image)<image>
rating(?string $rating)<rating>PICS rating
textInput(?TextInput $textInput)<textInput>
skipHour(int $hour)<skipHours><hour>0–23, duplicates ignored
skipDay(string $day)<skipDays><day>A Day constant, duplicates ignored
addItem(Item $item)<item>
addItems(iterable $items)<item>Any iterable of Item
extension(Extension $extension)namespacedWritten after the core channel elements

Output

MethodDescriptionReturns
toXml(bool $pretty = true)Validates and serializes the feed. false gives compact outputstring

toXml() is a shortcut for (new RssWriter($pretty))->write($feed). You can use Pharaonic\RSS\Writer\RssWriter directly if you prefer to inject the writer.

Getters

MethodReturns
getTitle(), getLink(), getDescription()?string
getLanguage(), getCopyright(), getManagingEditor(), getWebMaster()?string
getGenerator(), getDocs(), getRating()?string
getPublishedAt(), getLastBuildAt()?DateTimeImmutable
getCategories()list<Category>
getCloud()?Cloud
getTtl()?int
getImage()?Image
getTextInput()?TextInput
getSkipHours()list<int>
getSkipDays()list<string>
getItems()list<Item>
getExtensions()list<Extension>

Item API

Pharaonic\RSS\Item is a final class. Every setter returns the same Item instance for chaining. Optional string setters treat null and '' as "not set".

Setters

MethodElementNotes
Item::make()Creates an empty item
title(?string $title)<title>Title or description is required
link(?string $link)<link>
description(?string $description)<description>HTML allowed, escaped in output
author(?string $author)<author>email (Name)
category(Category|string $category)<category>Adds one per call
comments(?string $comments)<comments>URL of the comments page
enclosure(?Enclosure $enclosure)<enclosure>
guid(Guid|string|null $guid, ?bool $isPermaLink = null)<guid>A string is a permalink unless the flag is false
publishedAt(?DateTimeInterface $date)<pubDate>RFC 822, keeps the offset
source(?Source $source)<source>
extension(Extension $extension)namespacedWritten after the core item elements

Getters

MethodReturns
getTitle(), getLink(), getDescription(), getAuthor(), getComments()?string
getCategories()list<Category>
getEnclosure()?Enclosure
getGuid()?Guid
getPublishedAt()?DateTimeImmutable
getSource()?Source
getExtensions()list<Extension>

Elements API

Structured elements are small final value objects. Each has a static make() with the same arguments as its constructor, and required values are validated on creation.

Pharaonic\RSS\Elements

ClassCreate withSettersGetters
Categorymake(string $value)domain(?string)getValue(), getDomain()
Guidmake(string $value)permalink(bool $isPermaLink = true)getValue(), isPermaLink()
Enclosuremake(string $url, int $length, string $type)getUrl(), getLength(), getType()
Imagemake(string $url)title(?string), link(?string), width(?int), height(?int), description(?string)getUrl(), getTitle(), getLink(), getWidth(), getHeight(), getDescription()
Sourcemake(string $title, string $url)getTitle(), getUrl()
Cloudmake(string $domain, int $port, string $path, string $registerProcedure, string $protocol)getDomain(), getPort(), getPath(), getRegisterProcedure(), getProtocol()
TextInputmake(string $title, string $description, string $name, string $link)getTitle(), getDescription(), getName(), getLink()
ConstraintRule
Enclosure length>= 0 bytes
Image width1 to Image::MAX_WIDTH (144)
Image height1 to Image::MAX_HEIGHT (400)
Cloud port1 to 65535
Cloud protocolA CloudProtocol constant

Pharaonic\RSS\Extensions

ClassCreate withSetters
Atom\Linkmake(string $href), self(string $href)rel(), type(), hreflang(), title(), length(?int)
Content\Encodedmake(string $content)Getter: getContent()
DublinCore\Creatormake(string $name)Getter: getName()
Media\Contentmake(string $url)fileSize(), type(), medium(), isDefault(), expression(), bitrate(), duration(), width(), height(), lang(), title(?Title), description(?Description), thumbnail(Thumbnail)
Media\Thumbnailmake(string $url)width(), height(), time(?string)
Media\Titlemake(string $text)type(?string); getters getText(), getType()
Media\Descriptionmake(string $text)type(?string); getters getText(), getType()

Each extension namespace has an abstract base class exposing PREFIX and NAMESPACE_URI constants: AtomExtension, ContentExtension, DublinCoreExtension, and MediaExtension.

Pharaonic\RSS\Support

ClassMembers
DayMONDAY … SUNDAY, all(), isValid(string $day)
CloudProtocolXML_RPC, SOAP, HTTP_POST, all(), isValid(string $protocol)
DateFormatterformat(DateTimeInterface $date), e.g. "Tue, 06 Oct 2026 10:00:00 +0300"
XmlisValidText(), assertValidText(), isNcName(), writeElement(), writeText(), writeAttribute(), writeCdata()
NamespaceRegistryregister(string $prefix, string $uri), has(string $prefix), all()

Examples

Real-world feeds built with PHP RSS.

1. Blog Feed in a Laravel App

Build the feed in a small class, return it from a controller with the right Content-Type, and cache the XML.

app/Rss/BlogFeed.php
1namespace App\Rss;
2 
3use App\Models\Post;
4use Pharaonic\RSS\Extensions\Atom\Link;
5use Pharaonic\RSS\Extensions\Content\Encoded;
6use Pharaonic\RSS\Extensions\DublinCore\Creator;
7use Pharaonic\RSS\Feed;
8use Pharaonic\RSS\Item;
9 
10final class BlogFeed
11{
12 public function toXml(): string
13 {
14 $posts = Post::query()->published()->latest('published_at')->limit(20)->get();
15 
16 return Feed::make()
17 ->title(config('app.name') . ' Blog')
18 ->link(url('/blog'))
19 ->description('News & releases from our team.')
20 ->language(app()->getLocale())
21 ->lastBuildAt($posts->first()?->updated_at)
22 ->ttl(60)
23 ->extension(Link::self(route('blog.rss')))
24 ->addItems($posts->map(fn (Post $post) => Item::make()
25 ->title($post->title)
26 ->link(route('blog.show', $post))
27 ->description($post->excerpt)
28 ->guid(route('blog.show', $post))
29 ->publishedAt($post->published_at)
30 ->extension(Creator::make($post->author->name))
31 ->extension(Encoded::make($post->body_html))))
32 ->toXml();
33 }
34}
app/Http/Controllers/BlogFeedController.php
1namespace App\Http\Controllers;
2 
3use App\Rss\BlogFeed;
4use Illuminate\Support\Facades\Cache;
5 
6class BlogFeedController
7{
8 public function __invoke(BlogFeed $feed)
9 {
10 $xml = Cache::remember('blog.rss', now()->addHour(), fn () => $feed->toXml());
11 
12 return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
13 }
14}
routes/web.php
1use App\Http\Controllers\BlogFeedController;
2 
3Route::get('/blog/rss.xml', BlogFeedController::class)->name('blog.rss');

$posts->map() returns a Laravel collection, which addItems() accepts because it's iterable. lastBuildAt(null) is fine when there are no posts yet.

2. Podcast Feed

Each episode carries an <enclosure> for classic podcast apps and a media:content for richer readers.

podcast.php
1use Pharaonic\RSS\Elements\Enclosure;
2use Pharaonic\RSS\Elements\Image;
3use Pharaonic\RSS\Extensions\Atom\Link;
4use Pharaonic\RSS\Extensions\DublinCore\Creator;
5use Pharaonic\RSS\Extensions\Media\Content;
6use Pharaonic\RSS\Extensions\Media\Thumbnail;
7use Pharaonic\RSS\Feed;
8use Pharaonic\RSS\Item;
9 
10$feed = Feed::make()
11 ->title('Pharaonic Podcast')
12 ->link('https://pharaonic.dev/podcast')
13 ->description('Conversations about building software.')
14 ->language('en')
15 ->image(Image::make('https://cdn.pharaonic.dev/podcast/cover.png'))
16 ->extension(Link::self('https://pharaonic.dev/podcast/rss.xml'));
17 
18foreach ($episodes as $episode) {
19 $feed->addItem(
20 Item::make()
21 ->title(sprintf('Episode %d: %s', $episode->number, $episode->title))
22 ->enclosure(Enclosure::make($episode->audioUrl, $episode->bytes, 'audio/mpeg'))
23 ->guid('pharaonic-podcast-' . $episode->number, false)
24 ->publishedAt($episode->publishedAt)
25 ->extension(Creator::make('Pharaonic'))
26 ->extension(
27 Content::make($episode->audioUrl)
28 ->fileSize($episode->bytes)
29 ->type('audio/mpeg')
30 ->medium(Content::MEDIUM_AUDIO)
31 ->expression(Content::EXPRESSION_FULL)
32 ->duration($episode->seconds)
33 )
34 ->extension(Thumbnail::make($episode->coverUrl))
35 );
36}
37 
38file_put_contents(__DIR__ . '/public/podcast/rss.xml', $feed->toXml());

Episodes without a description are valid because each one has a title.

3. Static Feed Generated at Deploy Time

For static sites, write the feed to disk once per build, compact, so the web server can serve it directly.

bin/build-feed.php
1use Pharaonic\RSS\Exceptions\RssException;
2use Pharaonic\RSS\Feed;
3use Pharaonic\RSS\Item;
4 
5require __DIR__ . '/../vendor/autoload.php';
6 
7$pages = json_decode(file_get_contents(__DIR__ . '/../content/pages.json'), true);
8 
9$feed = Feed::make()
10 ->title('Docs Changelog')
11 ->link('https://docs.example.com')
12 ->description('Every documentation update.')
13 ->lastBuildAt(new DateTimeImmutable('now', new DateTimeZone('UTC')));
14 
15foreach ($pages as $page) {
16 $feed->addItem(
17 Item::make()
18 ->title($page['title'])
19 ->link($page['url'])
20 ->guid($page['url'])
21 ->publishedAt(new DateTimeImmutable($page['updated_at']))
22 );
23}
24 
25try {
26 file_put_contents(__DIR__ . '/../public/feed.xml', $feed->toXml(false));
27} catch (RssException $e) {
28 fwrite(STDERR, 'Feed build failed: ' . $e->getMessage() . PHP_EOL);
29 exit(1);
30}

Failing the build on an RssException keeps an invalid feed from ever reaching readers.

4. Aggregated Feed with Sources

When you republish items from other feeds, credit each origin with source() and keep the original GUID.

aggregate.php
1use Pharaonic\RSS\Elements\Category;
2use Pharaonic\RSS\Elements\Source;
3use Pharaonic\RSS\Feed;
4use Pharaonic\RSS\Item;
5 
6$feed = Feed::make()
7 ->title('PHP Weekly Picks')
8 ->link('https://picks.example.com')
9 ->description('The best PHP articles from around the web.');
10 
11foreach ($picks as $pick) {
12 $feed->addItem(
13 Item::make()
14 ->title($pick['title'])
15 ->link($pick['link'])
16 ->description($pick['summary'])
17 ->category(Category::make($pick['topic'])->domain('https://picks.example.com/topics'))
18 ->guid($pick['guid'], $pick['guid_is_url'])
19 ->source(Source::make($pick['site_name'], $pick['site_feed_url']))
20 );
21}
22 
23echo $feed->toXml();

Troubleshooting

"The RSS channel requires a non-empty title"

The channel is missing one of its three required elements. Call title(), link(), and description() with non-blank values before toXml(). Whitespace-only values count as blank.

"The RSS item at position N requires at least a title or a description"

The item at zero-based position N has neither. This often happens when a database column is null. Set at least one of them, or skip the record.

"contains invalid UTF-8 or characters that are not allowed in XML 1.0"

The text contains broken UTF-8 or control characters such as \x0B or \x1F, usually from pasted or legacy content. XML 1.0 can't represent them, so the package refuses to write them. Clean the value before adding it:

1$clean = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', mb_convert_encoding($text, 'UTF-8', 'UTF-8'));

HTML Shows as Escaped Tags in My Reader

description() escapes HTML (&lt;p&gt;), which is correct RSS 2.0, and readers decode it. Some readers prefer the full body as CDATA. Add Encoded::make($html) from Pharaonic\RSS\Extensions\Content alongside the description.

The Browser Downloads or Shows the Feed as Text

The response is missing a proper Content-Type. The package never sends headers. Send Content-Type: application/rss+xml; charset=UTF-8 from your application, and make sure nothing is printed before the XML declaration.

Dates Show the Wrong Time

Dates are formatted with the timezone of the object you pass, never the server's. A DateTimeImmutable created without a timezone uses date.timezone from php.ini. Pass an explicit DateTimeZone, or call ->setTimezone() before adding the date.

Readers Show Duplicate Items

The item has no GUID, or its GUID changes between builds. Set a stable guid() for every item, such as the permalink or a database ID with false as the second argument.

"already bound to … and cannot also be bound to …"

Two extensions use the same prefix with different namespace URIs, often a custom extension reusing media, dc, content, or atom. Give your custom extension a different prefix, or use the same URI as the built-in one.

"Item::guid() received a Guid object and an isPermaLink flag"

You passed both a Guid object and the second argument. Use either guid('id', false) or guid(Guid::make('id')->permalink(false)).

Contributors

Amazing people who made this package possible

This package wouldn't exist without the contributions of our amazing community. A huge thank you to everyone who has helped improve Rss!

Moamen Eltouny

@MoamenEltouny

43 contributions

Want to Contribute?

We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.