thujohn/rss
thujohn/rss is a lightweight RSS feed generator for Laravel applications. It provides a simple API for defining channels, adding items, and outputting valid RSS/XML, making it easy to expose blog posts, news, or other dynamic content as syndication feeds.
Requires Laravel 4 only — do not attempt to install this on L5+. Pull it in with Composer:
composer require thujohn/rss
Register the service provider and facade alias in app/config/app.php:
'providers' => [
// ...
'Thujohn\Rss\RssServiceProvider',
],
'aliases' => [
// ...
'RSS' => 'Thujohn\Rss\RssFacade',
],
The first thing to look at is vendor/thujohn/rss/src/Thujohn/Rss/Rss.php — it is a very thin wrapper around suin/php-rss-writer, so the API surface is basically feed(), channel(), item(), and render(). Everything else (valid channel elements, item elements) is documented by that upstream library, not by this package.
A minimal working feed route (app/routes.php):
Route::get('feed', function () {
RSS::feed('2.0', 'UTF-8');
RSS::channel([
'title' => 'My Blog',
'description' => 'Recent posts',
'link' => URL::to('/'),
]);
foreach (Post::latest()->take(20)->get() as $post) {
RSS::item([
'title' => $post->title,
'description' => $post->excerpt,
'link' => URL::to("posts/{$post->slug}"),
'pubDate' => $post->created_at->toRfc2822String(),
'guid' => URL::to("posts/{$post->slug}"),
]);
}
return Response::make(RSS::render(), 200, [
'Content-Type' => 'application/rss+xml; charset=UTF-8',
]);
});
That last part is the whole point of the package: you build the feed imperatively and get back an XML string. Serving it with the right Content-Type is your job, not the package's.
Dedicated feed controller. Keep feed-building out of closures as soon as you have more than one feed. A FeedController with one method per feed (posts, comments, podcast) makes routes readable and lets you reuse a private respond() helper that wraps RSS::render() with the correct headers.
One request, one feed. The facade resolves a shared instance; treat each feed as a self-contained method that starts with RSS::feed(...). If you need two feeds in one request (rare — e.g. an index page rendering two previews), build and render() the first before starting the second, and verify the output because state leakage produces feeds with duplicated items.
Loop + array mapping. The idiomatic pattern is to chunk your query and map models into plain arrays:
Post::latest()->take(50)->get()->each(function ($post) {
RSS::item([
'title' => $post->title,
'link' => route('posts.show', $post->slug),
'description' => strip_tags($post->body),
'pubDate' => $post->created_at->toRfc2822String(),
'guid' => route('posts.show', $post->slug),
'author' => $post->author->email . ' (' . $post->author->name . ')',
]);
});
Cache aggressively. Feeds are read by crawlers far more often than they change. Cache::remember('feed.posts', 15, function () { ... return RSS::render(); }) and return the cached string — this also sidesteps the cost of hydrating hundreds of Eloquent models on every poll.
Podcasts / media. Use the enclosure item key with url, length, and type for audio. This is the main reason people reach for a hand-rolled builder instead of a static feed generator.
Atom when required. RSS::feed('atom', 'UTF-8') switches the output format; the channel()/item() call shape stays the same, but element names and the required-element set change (Atom wants id, updated, author). Validate separately — RSS 2.0-valid code is not automatically Atom-valid.
Header via a filter or response macro. If you have several feeds, register a response macro or after-filter in L4 that sets application/rss+xml whenever the route name starts with feed., so nobody forgets the header on a new endpoint.
spatie/laravel-feed or willvincent/feed; keep this knowledge for maintaining legacy apps. That constraint explains the low dependents count.Content-Type is the #1 bug. Without the header, browsers render the XML as plain text or, worse, as HTML with tags stripped. Always return a Response with application/rss+xml.feed() must come first, channel() before any item(). The underlying writer throws if you add an item before a channel exists. Putting channel() inside the loop is a classic mistake and yields duplicated channel metadata.render() on a feed with zero items raises an exception from the upstream writer rather than returning an empty feed. Guard with if ($posts->isEmpty()) { return Response::make('', 404); } or always emit a placeholder.pubDate is a string, not a DateTime. The package passes values straight through to the XML writer. Hand it a Carbon instance and you get either a stringified object or a malformed date. Always ->toRfc2822String() (or date('r', $timestamp)) for RSS 2.0.<strong> in a description becomes escaped text in many readers. If you intend rendered HTML in descriptions, wrap in CDATA explicitly — the upstream writer does not do this for you, so either pre-format the string or accept plain text via strip_tags().RSS::render() to a file (file_put_contents('/tmp/feed.xml', RSS::render())) and run it through xmllint --noout plus the W3C Feed Validator. Errors from the upstream writer are often terse; validating the XML is faster than reading stack traces.return RSS::render(); from a controller. In L4 that becomes a 200 response with text/html, and every browser tab shows markup instead of the feed. Wrap it, always.'UTF-8' to feed() and ensure your database and strip_tags()-processed strings are actually UTF-8; mismatched encoding yields invalid XML that validators reject with byte-level errors that are painful to trace back.How can I help you explore Laravel packages today?