roumen/feed
A Laravel package for generating RSS and Atom feeds from application data. Build custom feeds with a fluent API, caching, and clean XML output for blogs, news, podcasts, and other dynamic content.
composer require roumen/feed:^1.0.3
v1.0.3 is a security patch — it escapes CDATA terminators to prevent XML injection. Anything rendering user-supplied content into a feed should be on >=1.0.3.php artisan vendor:publish --provider="Roumen\Feed\FeedServiceProvider"
config/app.php and add the Feed alias if the package exposes one. Then run:
php artisan package:discover
php artisan config:clear
config/feed.php for defaults such as format, cache, and view paths.resources/views/vendor/feed/rss.blade.php and atom.blade.php after publishing, because most output quirks are view quirks — and because published views are where you can reintroduce the injection bug the patch just fixed.Feed class for the exact add(), render(), setCache(), setShortening(), and setTextLimit() signatures in your installed version./feed route to a blog or news site. Build the feed from the latest published posts, return RSS, and link to it from your layout:
Route::get('/feed', FeedController::class)->name('feed');
A minimal controller:
public function __invoke()
{
$feed = app('feed');
$feed->title = config('app.name').' Blog';
$feed->description = 'Latest articles';
$feed->link = route('feed');
$feed->lang = app()->getLocale();
$feed->setDateFormat('datetime');
$posts = Post::with('author')->latest('published_at')->limit(20)->get();
$feed->pubdate = $posts->first()?->published_at ?? now();
foreach ($posts as $post) {
$feed->add(
$post->title,
$post->author->name,
route('posts.show', $post),
$post->published_at,
$post->excerpt,
$post->body
);
}
return $feed->render('rss');
}
Use one controller or builder per feed. Keep feed construction out of routes and models. A dedicated FeedController or FeedBuilder service makes it easy to test the XML, reuse metadata, and swap the package later.
Cache the rendered feed, not the models. The package supports feed caching; pair it with a key that includes the feed type, locale, tenant, and category:
$feed->setCache(10, 'feed:blog:'.app()->getLocale());
if (! $feed->isCached()) {
$posts = Post::with('author')->latest('published_at')->limit(20)->get();
foreach ($posts as $post) {
$feed->add(
$post->title,
$post->author->name,
route('posts.show', $post),
$post->published_at,
$post->excerpt,
$post->body
);
}
}
return $feed->render('rss');
Invalidate on content changes. Add model events or an observer that clears the feed cache when a post is published, updated, unpublished, or deleted:
static::saved(fn () => Cache::forget('feed:blog:en'));
static::deleted(fn () => Cache::forget('feed:blog:en'));
For multi-locale or multi-tenant feeds, version the cache key instead of trying to forget every variation:
Cache::forget('feed:blog:en:v2');
Separate RSS and Atom cache keys. Do not let render('rss') and render('atom') share a cache key. Their XML structures and date formats differ.
Use absolute URLs. Feed readers do not resolve relative links reliably. Use route() or url() for item links, the feed link, and images.
Eager load and limit. Feed readers poll often. Load at most 20–50 items, eager load relationships such as authors, and avoid queries inside the item loop.
Customize through published views. Most day-to-day customization is easier in the published Blade views than by modifying package code: add <enclosure> for podcast episodes, <category> for tags, media RSS namespaces, or fuller HTML content. Keep the XML declaration as the first bytes of the file.
Let the package do the escaping — don't pre-escape. Since v1.0.3 the package neutralizes ]]> inside CDATA sections, so pass raw text ($post->title, $post->body) and let it handle output encoding. If you hand-roll <![CDATA[ ... ]]> blocks in a published view, you opt out of that protection and own the sanitization yourself.
Sanitize rich HTML before it reaches the feed. The CDATA fix prevents structural XML breakage, not stored XSS or unwanted markup in readers. Run post bodies through an HTML sanitizer (e.g. mews/purifier) and strip scripts/iframes before add().
Wrap add() in a helper if you call it often. The positional signature is easy to mix up:
$feed->add($title, $author, $link, $date, $description, $content);
A small addPostToFeed($feed, $post) helper avoids passing excerpt and body in the wrong order.
Return the correct content type. If render() returns a string in your installed version, wrap it explicitly:
return response($feed->render('rss'), 200, [
'Content-Type' => 'application/rss+xml; charset=UTF-8',
]);
If the package returns a response object already, return it directly and verify the header in a browser or with curl -I.
>=1.0.3 if anything user-authored lands in the feed. Before this patch, a title, excerpt, or body containing ]]> could terminate the CDATA block early and inject arbitrary XML into your feed — a real vector for anyone allowed to post comments, listings, or profile text. After upgrading, composer update roumen/feed and re-run your feed test.resources/views/vendor/feed/*.blade.php against the shipped views on upgrade, or delete and re-publish them.<?xml version="1.0" encoding="UTF-8"?> makes the feed invalid. Check the published view starts with the XML declaration at byte zero.&, <, and >.setDateFormat() handles every case.pubdate values produce warnings. Set a fallback date, usually the latest item date or now(), so validators and readers have something to work with.setShortening() and setTextLimit() can cut UTF-8 or HTML mid-tag. They truncate by character count, not by DOM node — a truncation applied before CDATA wrapping can leave an unclosed <p> or a split entity. Test with non-English content and HTML excerpts. For full-content feeds, disable shortening and control the content yourself.xmllint --noout feed.xml, and keep an assertion that a title containing ]]> still produces well-formed XML:
$post->update(['title' => 'Hack ]]> <script>alert(1)</script>']);
$this->get('/feed')->assertSee(']]]]><![CDATA[>', false);
Feed class in your own service if you need organization-specific defaults, then bind it in the container. That keeps package-specific calls isolated and makes a future migration to native XML or another feed package much cheaper — and means a future security patch only has to be applied in one place.How can I help you explore Laravel packages today?