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.
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.2
- The
xmlwriterextension (ext-xmlwriter), bundled with most PHP builds
You can check that the extension is enabled with:
php -m | grep -i xmlwriterComposer Installation
composer require pharaonic/php-rssNamespace
Every class lives under the Pharaonic\RSS namespace:
| Namespace | Contains |
|---|---|
Pharaonic\RSS | Feed, Item |
Pharaonic\RSS\Elements | Category, Guid, Enclosure, Image, Source, Cloud, TextInput |
Pharaonic\RSS\Extensions\* | Atom, Content, Dublin Core, and Media RSS elements |
Pharaonic\RSS\Contracts | Extension |
Pharaonic\RSS\Support | Day, CloudProtocol, DateFormatter, Xml, NamespaceRegistry |
Pharaonic\RSS\Exceptions | RssException and its subclasses |
Pharaonic\RSS\Writer | RssWriter |
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.
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:
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 & 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><p>A modern <strong>RSS 2.0</strong> generator for PHP.</p></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 $posts8));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 read2$feed->toXml(false); // compact, smallest sizeRules Worth Knowing
- Empty values are omitted. Passing
nullor''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. Usecontent:encodedto send it as CDATA instead. - Dates keep their timezone. Any
DateTimeInterfaceis 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()orecho.
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$feed2 ->language('ar-EG')3 ->copyright('© Pharaonic')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))');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$feed2 ->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$feed4 ->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$feed4 ->ttl(60) // readers may cache for 60 minutes5 ->skipHour(0) // 0-23, GMT6 ->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$feed6 ->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>')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));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$item4 ->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.
| Extension | Namespace prefix | Classes |
|---|---|---|
| Atom | atom | Pharaonic\RSS\Extensions\Atom\Link |
| Content | content | Pharaonic\RSS\Extensions\Content\Encoded |
| Dublin Core | dc | Pharaonic\RSS\Extensions\DublinCore\Creator |
| Media RSS | media | Pharaonic\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$item4 ->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'));| Constant | Values |
|---|---|
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.
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 Extension2{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:
| Helper | Writes |
|---|---|
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
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(): string14 {15 return 'geo';16 }17 18 public function namespaceUri(): string19 {20 return 'http://www.w3.org/2003/01/geo/wgs84_pos#';21 }22 23 public function write(XMLWriter $writer): void24 {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:
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(): string15 {16 return 'itunes';17 }18 19 public function namespaceUri(): string20 {21 return 'http://www.itunes.com/dtds/podcast-1.0.dtd';22 }23 24 public function write(XMLWriter $writer): void25 {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
xmlorxmlns. - 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.
| Exception | Thrown when |
|---|---|
InvalidFeedException | The channel is missing a title, link, or description, or gets a negative ttl, an hour outside 0–23, or an unknown skip day |
InvalidItemException | An item has neither a title nor a description, or guid() gets a Guid object together with a permalink flag |
InvalidElementException | An 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
| Check | Runs at |
|---|---|
| Empty URL, value, or name in an element or extension | Construction (make() / new) |
Numeric ranges, choices (medium, protocol, width…) | The setter call |
Channel title, link, description | toXml() |
| Item title-or-description | toXml() |
| Namespace prefixes and conflicts | toXml() |
| Invalid UTF-8 and XML control characters | toXml() |
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:
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".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
| Method | Element | Notes |
|---|---|---|
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) | namespaced | Written after the core channel elements |
Output
| Method | Description | Returns |
|---|---|---|
toXml(bool $pretty = true) | Validates and serializes the feed. false gives compact output | string |
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
| Method | Returns |
|---|---|
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
| Method | Element | Notes |
|---|---|---|
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) | namespaced | Written after the core item elements |
Getters
| Method | Returns |
|---|---|
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
| Class | Create with | Setters | Getters |
|---|---|---|---|
Category | make(string $value) | domain(?string) | getValue(), getDomain() |
Guid | make(string $value) | permalink(bool $isPermaLink = true) | getValue(), isPermaLink() |
Enclosure | make(string $url, int $length, string $type) | getUrl(), getLength(), getType() | |
Image | make(string $url) | title(?string), link(?string), width(?int), height(?int), description(?string) | getUrl(), getTitle(), getLink(), getWidth(), getHeight(), getDescription() |
Source | make(string $title, string $url) | getTitle(), getUrl() | |
Cloud | make(string $domain, int $port, string $path, string $registerProcedure, string $protocol) | getDomain(), getPort(), getPath(), getRegisterProcedure(), getProtocol() | |
TextInput | make(string $title, string $description, string $name, string $link) | getTitle(), getDescription(), getName(), getLink() |
| Constraint | Rule |
|---|---|
Enclosure length | >= 0 bytes |
Image width | 1 to Image::MAX_WIDTH (144) |
Image height | 1 to Image::MAX_HEIGHT (400) |
Cloud port | 1 to 65535 |
Cloud protocol | A CloudProtocol constant |
Pharaonic\RSS\Extensions
| Class | Create with | Setters |
|---|---|---|
Atom\Link | make(string $href), self(string $href) | rel(), type(), hreflang(), title(), length(?int) |
Content\Encoded | make(string $content) | Getter: getContent() |
DublinCore\Creator | make(string $name) | Getter: getName() |
Media\Content | make(string $url) | fileSize(), type(), medium(), isDefault(), expression(), bitrate(), duration(), width(), height(), lang(), title(?Title), description(?Description), thumbnail(Thumbnail) |
Media\Thumbnail | make(string $url) | width(), height(), time(?string) |
Media\Title | make(string $text) | type(?string); getters getText(), getType() |
Media\Description | make(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
| Class | Members |
|---|---|
Day | MONDAY … SUNDAY, all(), isValid(string $day) |
CloudProtocol | XML_RPC, SOAP, HTTP_POST, all(), isValid(string $protocol) |
DateFormatter | format(DateTimeInterface $date), e.g. "Tue, 06 Oct 2026 10:00:00 +0300" |
Xml | isValidText(), assertValidText(), isNcName(), writeElement(), writeText(), writeAttribute(), writeCdata() |
NamespaceRegistry | register(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.
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 BlogFeed11{12 public function toXml(): string13 {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} 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}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.
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.
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.
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 (<p>), 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!
Want to Contribute?
We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.