laravelium/feed
Generate RSS 2.0 and Atom 1.0 feeds with a framework-agnostic PHP library for Laravel, Symfony, and any project. Includes built-in caching, custom views, dependency injection, and full test coverage.
1. Install and confirm the package name. The repo is RumenDamyanov/php-feed and the Composer package is rumenx/php-feed — not laravelium/feed. Installing the wrong name will get you the abandoned legacy package.
composer require rumenx/php-feed
Check composer.json afterwards: the platform requirement is PHP 8.3+. If your app or CI image is on 8.2, Composer will refuse to resolve, and that is the most common first stumbling block.
2. Write the smallest possible controller. There is no service provider, no facade, no published config file to hunt for. Everything starts at FeedFactory::create():
use Rumenx\Feed\FeedFactory;
class FeedController extends Controller
{
public function __invoke()
{
$feed = FeedFactory::create();
$feed->setTitle('My Blog');
$feed->setDescription('Latest posts');
$feed->setLink(config('app.url'));
$feed->addItem([
'title' => 'First Post',
'author' => 'Rumen',
'link' => 'https://example.com/post/1',
'pubdate' => now(),
'description' => 'This is the first post.',
]);
return response($feed->render('rss'), 200, [
'Content-Type' => 'application/rss+xml; charset=UTF-8',
]);
}
}
3. Register the route and advertise it. Feeds are useless if nothing links to them:
Route::get('/feed', FeedController::class)->name('feed');
<link rel="alternate" type="application/rss+xml"
title="{{ config('app.name') }}" href="{{ route('feed') }}">
4. Where to look first. The README's "API Reference" section is the entire public surface — setTitle, setDescription, setLink, setDateFormat, setLanguage, addItem, addItems, render, isCached, clearCache. There is no hidden magic beyond that. Read the Feed class constructor signature next, because that is where the adapter injection lives and where you will spend your customization effort.
5. First real use case. A blog index feed: pull the latest 20 published posts, map them to item arrays, render. Ship that, validate it in Feedly or the W3C feed validator, then layer caching on top.
Map models to items in one place. Do not scatter array literals across controllers. Put a toFeedItem(): array method on the model, or a small FeedItemMapper:
final class PostToFeedItem
{
public static function map(Post $post): array
{
return [
'title' => $post->title,
'author' => $post->author->name,
'link' => route('posts.show', $post),
'pubdate' => $post->published_at,
'description' => Str::limit(strip_tags($post->body), 500),
];
}
}
Then $feed->addItems($posts->map(fn ($p) => PostToFeedItem::map($p))->all());. This keeps the feed contract stable when the model changes and makes the mapping unit-testable.
Wrap it in a FeedBuilder service. You will inevitably need more than one feed (main, per-category, per-tag, per-author, per-locale). A thin service that accepts a query builder and channel metadata beats copy-pasting controllers:
class FeedBuilder
{
public function build(string $title, string $link, string $description, iterable $items, string $format = 'rss'): string
{
$feed = FeedFactory::create();
$feed->setTitle($title)->setDescription($description)->setLink($link);
$feed->setLanguage(app()->getLocale());
$feed->addItems(collect($items)->all());
return $feed->render($format);
}
}
Bind it as a singleton in AppServiceProvider if you add adapters (see below); otherwise a plain newable class is fine.
Choose format by route, not by controller. One route with a validated segment keeps the URL space clean:
Route::get('/feed/{format?}', FeedController::class)
->whereIn('format', ['rss', 'atom'])
->defaults('format', 'rss');
In the controller: $format = $request->route('format'); and pick the matching Content-Type (application/rss+xml vs application/atom+xml). Never let an unvalidated string reach render().
Cache at the HTTP layer first, then at the feed layer. The package exposes isCached()/clearCache() but the cache adapter is yours to supply in Laravel. The pragmatic order:
Cache::remember("feed:{$format}:{$locale}", now()->addMinutes(10), fn () => $feed->render($format)) — simplest, works with any driver, and you control the key.ETag / Last-Modified headers so aggregators can do conditional GETs. Feed readers poll aggressively; a 304 saves you the whole render.$xml = Cache::remember($key, 600, fn () => $builder->build(...));
$etag = '"' . md5($xml) . '"';
if ($request->header('If-None-Match') === $etag) {
return response('', 304);
}
return response($xml, 200, [
'Content-Type' => 'application/rss+xml; charset=UTF-8',
'ETag' => $etag,
'Cache-Control' => 'public, max-age=600',
]);
Invalidate on write, not on a timer alone. A feed that lags 10 minutes after publishing is a support ticket. Hook your publish flow:
// In an observer or model event
Cache::forget("feed:rss:en");
Cache::forget("feed:atom:en");
If you have many feeds, use cache tags (Redis/Memcached) or a versioned key prefix (feed:v{$version}:...) bumped on publish — this avoids hunting down every key.
Custom views for anything beyond the basics. The README shows getItems(), getTitle(), etc. for handing data to your own Blade template. Reach for this when you need <enclosure> for podcast episodes, <media:content>, <content:encoded> for full-text feeds, or Dublin Core namespaces — none of which are in the documented item schema. Render feed::rss-style Blade views and you keep the package for data collection while owning the XML.
Provide Laravel-native adapters for new Feed([...]). The advanced API takes cache, config, response, and view adapters. This is the right move if you want render() to return a real Illuminate\Http\Response or if you want config to read from config('feed.*'). Implement FeedCacheInterface against Illuminate\Support\Facades\Cache, FeedViewInterface against view(), and keep them in app/Feeds/Adapters/.
Test the XML, not the string. Use SimpleXML or DOMDocument in assertions so that whitespace changes or attribute reordering do not create false failures:
$response = $this->get('/feed');
$response->assertOk()->assertHeader('Content-Type', 'application/rss+xml; charset=UTF-8');
$xml = simplexml_load_string($response->getContent());
$this->assertSame('My Blog', (string) $xml->channel->title);
$this->assertCount(20, $xml->channel->item);
Freeze time with Carbon::setTestNow() so pubdate is deterministic, and assert the RFC-2822/RFC-3339 serialization explicitly — date format bugs are the number one reason feeds validate in one reader and not another.
Warm the cache on a schedule. For high-traffic feeds, a php artisan feed:warm command run every N minutes means no user request ever pays the render cost:
Schedule::command('feed:warm')->everyTenMinutes()->withoutOverlapping();
render() returns mixed, and that means what you think it means. In the plain-PHP path it returns an XML string. If you inject a response adapter, it returns a framework response object. Mixing these up — echo $feed->render() inside a Laravel controller, or response($feed->render()) when your adapter already returned a Response — produces either a doubled body or a "Response is not a string" error. Pick one pattern per project and stay there. The response($xml, 200, [...]) form is the least surprising.
Whitespace before <?xml breaks everything. Any stray newline or BOM emitted by a Blade template, a ?> closing tag in an included file, or a debug dd() before the response will make the feed unparseable in every reader with a cryptic error. If a feed renders fine in the browser but fails in Feedly, dump bin2hex(substr($xml, 0, 8)) and look for efbbbf (BOM) or 0a3c (newline before <).
All links must be absolute. RSS and Atom have no notion of a base path. route() gives you absolute URLs by default, but url('/posts/1') vs '/posts/1' in hand-written arrays is a real source of broken feeds. Same for image URLs inside descriptions and enclosures. If APP_URL is wrong in production, every feed link is wrong — check it during deploy verification, not after.
Escape HTML in description; use a custom view for content:encoded. The built-in renderers handle XML entity escaping of the description string, but if you stuff raw HTML in and expect it to render as markup in readers, that is a different element (CDATA or content:encoded). Decide per-feed whether you are shipping excerpts (escape, strip tags) or full text (custom template with CDATA). Mixing the two produces feeds that look correct in the validator and broken in readers.
Date formats differ between RSS and Atom. RSS 2.0 wants RFC 2822 (Thu, 21 Dec 2023 16:01:07 +0000); Atom wants RFC 3339 (2023-12-21T16:01:07+00:00). Use setDateFormat() deliberately per format, or verify the default handles both. Passing a Carbon instance is fine (DateTimeInterface), but passing an arbitrary string means you own the correctness. Test both formats with a frozen clock.
The cache adapter does nothing until you wire it. isCached()/clearCache() operate on whatever FeedCacheInterface implementation you injected. FeedFactory::create() uses simple built-in adapters — which likely means an in-request or null cache, not Laravel's cache store. Do not assume Cache::forget() in your model observer invalidates whatever the package is reading. Either use Cache::remember() around render() (recommended, explicit) or write a FeedCacheInterface backed by Laravel's Cache facade and inject it. This distinction has burned people; it is worth a five-minute read of the adapter classes in src/.
Cache keys must include format, locale, and any filter. /feed/rss and /feed/atom render different XML from the same data. /feed?tag=php is a different feed. A single key like 'feed' will happily serve Atom XML to an RSS client. Build keys as implode(':', ['feed', $format, $locale, $tag ?? 'all', $page ?? 1]).
Pin the version. The package has zero dependents on Packagist, a single maintainer, and a 1.0.x line where releases have been dependency-bump-only. That is not a red flag, but it does mean you are the integration test suite. Commit a composer.lock (you should anyway), pin ^1.0, and wrap the public surface in your own FeedBuilder so a breaking change or an unmaintained package is a one-file swap rather than a grep across the codebase.
No service provider means no config/feed.php. Do not go looking for php artisan vendor:publish. Configuration lives wherever you put it: your own config/feed.php read at construction time, or a FeedConfigInterface adapter. Keep channel metadata (title, description, language, copyright, managing editor) in a config file rather than hard-coded in the controller — you will change it without a deploy, and it keeps controllers to three lines.
Add the charset to Content-Type. application/xml is legal but sloppy. application/rss+xml; charset=UTF-8 and application/atom+xml; charset=UTF-8 help readers and some proxies. Ensure your PHP source, database, and Blade templates are all UTF-8; a Latin-1 byte in a post title produces an invalid XML document with no hint as to which post caused it.
Debugging a malformed feed. In order: (1) curl -s https://yoursite/feed | xmllint --noout - for a precise line number; (2) the W3C Feed Validator for spec-level complaints; (3) drop the item count to 1 to isolate whether a specific record is the culprit; (4) check for control characters in titles (preg_replace('/[^\PC\s]/u', '', $title)). Aggregators cache aggressively — always bust with a query string (/feed?v=2) when testing changes.
Extension points to know. The four interfaces (FeedCacheInterface, FeedConfigInterface, FeedResponseInterface, FeedViewInterface) are the whole customization story. Of these, the cache and view adapters are the ones you will actually implement in a Laravel app. The response adapter is worth it only if you want render() to return an Illuminate\Http\Response — convenient, but it couples your call sites to the adapter, so most teams skip it and use response($xml, ...) explicitly.
How can I help you explore Laravel packages today?