rumenx/php-feed
rumenx/php-feed is a lightweight PHP library for generating RSS and Atom feeds. It offers a clean API for defining feed metadata and items, with support for enclosures and common extensions, so you can quickly produce valid XML for blogs, news, or podcasts. Ideal for Laravel and standalone PHP proje...
1. Install and pin
composer require rumenx/php-feed:^1.0.3
Pin to a caret-minor constraint and commit composer.lock. This is not a package you want floating on *. The floor matters: 1.0.3 is a security release — it fixes CDATA terminator escaping (a ]]> in your content could previously close the CDATA section and inject markup into the feed). If you already depend on this package, bump the constraint so the floor is 1.0.3 rather than letting an old lockfile keep you exposed.
The 1.0.3 diff is one functional change (that fix) plus CI dependency bumps. There are no API changes, so the upgrade is a drop-in.
2. Read the source before the README
The package is small by design. Spend ten minutes in src/ first — you want to answer three questions before writing code:
Feed/Builder class vs. separate RssFeed / AtomFeed classes)?Framework-agnostic means the Laravel and Symfony stories are "it works there," not "it is integrated there." Assume no service provider until you see one.
You can partially answer the escaping question from the changelog: the 1.0.3 fix presumes a CDATA code path exists, so HTML descriptions are supported rather than escaped-only. Find it in src/ and read it before you rely on it — that's the code that just had a CVE-shaped bug in it.
3. First use case: a blog RSS feed
The canonical first task is exposing /feed from your posts table. The shape is almost always: construct feed → set channel-level metadata → append items → render XML.
use RumenX\PhpFeed\Feed; // verify exact FQCN against src/
$feed = new Feed();
$feed->title('My Blog')
->link(config('app.url'))
->description('Writing about things')
->language('en')
->lastBuildDate(now());
foreach (Post::published()->latest()->take(20)->get() as $post) {
$feed->addItem(
$post->title,
route('posts.show', $post),
$post->excerpt,
$post->published_at,
(string) $post->id, // stable GUID
);
}
return response($feed->render(), 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
4. Look first at these three things
date() on your behalf).]]> sequences, and whether it does so for every field or only the description. Then decide whether you trust it with untrusted input (see Gotchas).Once a feed validates in the W3C Feed Validator, you have learned 90% of this package's surface area.
Controller vs. dedicated feed class
For a single feed, a thin controller is fine. Past two or three feeds (main, per-category, per-author, podcast), extract a FeedBuilder-style class per feed and keep controllers to routing plus a content-type header. Feed generation is presentation logic with a data dependency; treating it like a Blade view pays off.
final class BlogFeed
{
public function __construct(private Feed $feed) {}
public function render(): string
{
// channel metadata + items
return $this->feed->render();
}
}
Repository-driven items
Feed items are a projection of your domain, not your Eloquent models. Map in one place so the feed never leaks internal fields:
$feed->addItem(
title: $post->title,
link: route('posts.show', $post),
description: Str::limit(strip_tags($post->body), 300),
date: $post->published_at,
guid: 'post-'.$post->id,
);
Note the post- prefix. Namespacing GUIDs by resource type is cheap insurance if you later merge multiple content types into one feed.
Normalize content at the mapper boundary
Given that description content may traverse a CDATA writer, treat the item mapper as your sanitization choke point rather than auditing package internals on every upgrade:
$description = str_replace(']]>', ']]>', trim(strip_tags($post->body)));
That single line is defense in depth against exactly the class of bug 1.0.3 patched. It costs nothing and survives a package swap.
Cache the rendered XML, not the query
Rendering is pure string work, so cache the final output and let invalidation ride on your model events:
$xml = Cache::remember('feed:blog', now()->addMinutes(15), fn () => $blogFeed->render());
return response($xml)->header('Content-Type', 'application/rss+xml; charset=UTF-8');
Bust the key in an observer on Post::saved / Post::deleted. Caching the underlying collection instead is a false economy — you still pay rendering on every request, and you lose the ability to ETag the exact bytes you serve.
Conditional GET
Rendered feeds are ideal ETag candidates because they change rarely and are expensive-ish to build:
$etag = '"'.md5($xml).'"';
if ($request->header('If-None-Match') === $etag) {
return response('', 304)->header('ETag', $etag);
}
return response($xml)->header('ETag', $etag);
Feed readers poll aggressively. This single addition typically removes most of your feed traffic.
RSS vs. Atom
Most such packages expose both. Pick RSS 2.0 as the default for broadest reader compatibility, and add Atom as a second route only if you have a consumer that asks for it. Don't try to serve both from one endpoint with content negotiation unless you control the clients — readers are notoriously bad at sending Accept headers.
Route and discovery
Route::get('/feed', FeedController::class)->name('feed');
The package can't add the <link rel="alternate"> tag to your HTML, so do it yourself in your layout <head>:
<link rel="alternate" type="application/rss+xml" title="My Blog" href="/feed">
This is the single most-forgotten step and the reason "the feed works but nobody subscribes."
Multi-tenant / per-scope feeds
Generate the feed from a scope parameter rather than duplicating classes:
Route::get('/category/{category}/feed', fn (Category $category) =>
response($categoryFeed->forCategory($category)->render())
->header('Content-Type', 'application/rss+xml; charset=UTF-8')
);
Keep the item cap (20–50) global and configurable; unbounded feeds are the most common cause of slow responses and memory spikes.
Testing
Assert on structure, not on the whole string. Load the output through DOMDocument and XPath:
$dom = new DOMDocument();
$dom->loadXML($xml); // also proves your output is well-formed
$this->assertSame(20, $dom->getElementsByTagName('item')->length);
$this->assertSame('post-1', $dom->getElementsByTagName('guid')->item(0)->nodeValue);
loadXML throwing on invalid output is the fastest possible regression test for an escaping bug. Add one fixture with hostile-ish content — a post body containing ]]>, &amp;, and an embedded <script> — and assert the parsed output contains no injected element. That test would have caught 1.0.2's bug and will catch the next one.
Upgrade to 1.0.3 if you render any user-controlled HTML in descriptions. The fix escapes CDATA terminators so a payload containing ]]> can't terminate the CDATA section and inject arbitrary XML into your feed. This is the highest-severity class of issue for a feed generator: the output is consumed by third-party readers and aggregators, and the blast radius is your subscribers, not your server. Check composer.lock on every project — a ^1.0.0 constraint that resolved months ago will not have moved.
Dates are the #1 source of invalid feeds. RSS 2.0 requires RFC 2822 (D, d M Y H:i:s O), Atom requires RFC 3339. If the package accepts a DateTimeInterface, pass one — never a preformatted string, or you'll get double-formatting. If it accepts a string, check what it does with it. Always set an explicit timezone on your dates; a UTC timestamp rendered as local time shifts your feed by hours for readers in other regions.
Absolute URLs only. Relative links (/posts/1) pass your eyes and fail every reader. Wrap item links in route() / url(), and set the channel link from config('app.url') so staging feeds don't leak production URLs.
GUID stability is a contract. Readers use GUID to deduplicate. If yours derive from the permalink, changing a slug re-delivers every affected post to every subscriber. Use an immutable identifier: post-{id}, or the creation timestamp. Once a feed is published, changing GUID semantics is a breaking change you cannot walk back.
Escaping vs. CDATA — read the writer, don't assume. The package has a real CDATA path, which is what you want for HTML excerpts. But CDATA is not escaping: inside a CDATA section nothing is interpreted except ]]>, so the only correct behavior is to split that sequence. Verify the implementation covers every field that can carry user content (title, description, author, category), and sanitize defensively in your mapper anyway. Never hand-wrap strings in CDATA yourself — double-escaping produces <![CDATA[ in output and is genuinely hard to spot in a browser (it looks fine; only the validator complains).
Empty feeds are not errors. A feed with zero items is valid and better than a 404 during a cold start or after a content purge. Guard against ->first() on an empty collection and always emit channel metadata.
Trim and normalize before you render. Titles with trailing newlines from a CMS, double-encoded entities (&amp;), and zero-width characters all survive into the feed and look broken in readers. A single strip_tags + trim + entity-decode pass in your item mapper catches most of it.
Memory on large exports. If you ever build a one-off "full archive" feed, the string-in-memory model will bite. Cap the item count (50 is plenty for a polled feed), or paginate into multiple feeds with rel="next" links in the channel. Don't try to stream with this class unless the source shows it supports it.
Debugging with curl, not the browser. Browsers render RSS as a styled page and hide malformed XML. Use:
curl -sI https://example.com/feed # content type, caching headers
curl -s https://example.com/feed | xmllint --noout - # well-formedness
Then run the output through the W3C Feed Validator — it catches date formats, missing required elements, and encoding declarations that xmllint won't. Note that a successful CDATA injection is well-formed XML and will pass xmllint; only inspecting the parsed structure (or the validator's element report) reveals it.
Watch the XML declaration vs. your response charset. If the package emits <?xml version="1.0" encoding="UTF-8"?> but your response header says charset=ISO-8859-1, readers will mangle accented characters. Set both, deliberately, to UTF-8.
Extension points are thin — plan accordingly. A framework-agnostic generator gives you a builder API and little else: no events, no middleware, no plugin contract. The leverage is in your wrapper (feed classes, caching, ETag), not in the package. Budget for writing that layer; don't expect to configure your way around a gap.
Vendor risk: slightly improved, still present. One maintainer, no downstream dependents — but 1.0.3 is a good signal: a security report turned around as a focused patch release with a test, and Dependabot is merged rather than ignored, so CI is being maintained. That is responsive solo maintenance, not abandonment. It's still a single point of failure, so keep your usage behind your own FeedBuilder interface so swapping to laminas/laminas-feed or spatie/laravel-feed (if you're on Laravel) is a one-file change rather than a search-and-replace. Worth the twenty minutes.
On Laravel specifically: check whether a service provider is auto-discovered (extra.laravel.providers in its composer.json) before assuming container bindings exist. If there isn't one, instantiate the class directly or bind it yourself in a small FeedServiceProvider — don't wait for a facade that isn't coming.
How can I help you explore Laravel packages today?