php-feed-io/feed-io
feed-io is a PHP library to read and write RSS, Atom, and JSONFeed feeds. It offers auto-discovery, PSR-7 responses with cache headers, PSR-18 HTTP client integration, a CLI, logging, and content filtering for only the newest items.
1. Install the library (not a Laravel package). There is no service provider, facade, or config file to publish — you wire it yourself.
composer require php-feed-io/feed-io
Check your version matrix first: feed-io 6.x requires PHP 8.1+ (Laravel 10/11/12), 5.x requires PHP 8.0+, 4.x requires PHP 7.1+. Pin a major version in composer.json — this is a small ecosystem (23 stars, no known dependents), so you want deliberate upgrades, not surprise minors.
2. Pick your HTTP client. feed-io 6 requires a PSR-18 client wrapped in FeedIo\Adapter\Http\Client, plus a PSR-3 logger. Laravel already ships Guzzle (guzzlehttp/guzzle is a framework dependency) and Guzzle 7's Client satisfies PSR-18, so you need zero extra packages:
// app/Providers/FeedIoServiceProvider.php
use FeedIo\Adapter\Http\Client as HttpAdapter;
use FeedIo\FeedIo;
use GuzzleHttp\Client as Guzzle;
use Psr\Log\LoggerInterface;
public function register(): void
{
$this->app->singleton(FeedIo::class, function ($app) {
$guzzle = new Guzzle([
'timeout' => 10,
'connect_timeout' => 5,
'headers' => ['User-Agent' => 'MyApp/1.0 (+https://example.com/bot)'],
]);
return new FeedIo(
new HttpAdapter($guzzle),
$app->make(LoggerInterface::class) // Laravel's Monolog logger is PSR-3
);
});
}
3. Probe before you code. The bundled CLI is the fastest sanity check that a URL is actually a feed and that it parses:
./vendor/bin/feedio read https://blog.laravel.com/feed
./vendor/bin/feedio discover https://laravel.com
discover prints every feed advertised in the page's <link rel="alternate"> headers — useful for building an "add feed" UI later.
4. First real use case — read a feed:
$result = app(\FeedIo\FeedIo::class)->read('https://blog.laravel.com/feed');
$result->getFeed()->getTitle(); // string|null
foreach ($result->getFeed() as $item) {
echo $item->getTitle();
echo $item->getLink();
echo $item->getLastModified()?->format(DATE_ATOM);
}
Where to look first: FeedIo\FeedIo (the facade: read, discover, toRss, toAtom, toJson, getPsrResponse), FeedIo\Reader\Result (feed, items-since, next-update), and the FeedIo\Feed / FeedIo\Feed\Item interfaces. The docs site (php-feed-io.github.io/feed-io) is far more complete than the README.
Poll in a queued job, back off with getNextUpdate(). This is the pattern that makes feed-io worth using over hand-rolled simplexml. Persist next_update_at per feed and skip execution until it's due; feed-io computes it from publishing cadence and marks stale ("sleepy") feeds as daily.
class PollFeed implements ShouldQueue
{
use WithoutOverlapping;
public function __construct(public FeedSource $source) {}
public function handle(FeedIo $feedIo): void
{
if ($this->source->next_update_at?->isFuture()) {
return;
}
try {
$result = $feedIo->read($this->source->url);
} catch (\FeedIo\Reader\ReadErrorException $e) {
$this->source->update(['next_update_at' => now()->addHour()]);
report($e);
return;
}
foreach ($result->getFeed() as $item) {
Article::firstOrCreate(
['guid' => $item->getLink() ?: $item->getTitle()],
[
'title' => $item->getTitle(),
'content' => $item->getDescription(),
'published' => $item->getLastModified(),
]
);
}
$this->source->update(['next_update_at' => $result->getNextUpdate()]);
}
}
Only ingest new items — pass $modifiedSince into read(). Don't fetch everything and diff in SQL. Persist the last seen timestamp per source and feed it back:
$result = $feedIo->read($source->url, new Feed, $source->last_seen_at);
foreach ($result->getItemsSince() as $item) { /* new items only */ }
$source->update(['last_seen_at' => now()]);
Filter chains for anything more than a date cut. getFilteredItems($chain) lets you compose FeedIo\Filter\Since with your own predicate (dedupe against the DB, keyword exclusion, language match) without touching the read layer:
$chain = new Chain();
$chain->add(new Since($result->getModifiedSince()))
->add(new ExcludeSeenArticles($source));
foreach ($result->getFilteredItems($chain) as $item) { ... }
Serve your own feed as an HTTP response with cache headers. getPsrResponse() gives you a genuine PSR-7 response with Last-Modified/cache semantics already computed — bridge it into a Laravel response rather than hand-setting headers:
public function show(FeedIo $feedIo): Response
{
$feed = new \FeedIo\Feed;
$feed->setTitle('My Blog');
$feed->setLink(route('home'));
$feed->setDescription('Latest posts');
foreach (Post::latest()->take(50)->get() as $post) {
$item = $feed->newItem();
$item->setTitle($post->title);
$item->setLink(route('posts.show', $post));
$item->setDescription($post->excerpt);
$item->setLastModified($post->published_at);
$feed->add($item);
}
$psr = $feedIo->getPsrResponse($feed, 'rss');
return response((string) $psr->getBody(), $psr->getStatusCode(), $psr->getHeaders());
}
Swap 'rss' for 'atom' or 'json' — same builder, three output formats. Register routes with explicit extensions (/feed.xml, /feed.json) so clients and proxies behave.
Podcast feeds: namespaces differ between feed-level and item-level. This asymmetry trips everyone up — a comma for set(), a colon for addElement():
$feed->setNS('itunes', 'http://www.itunes.com/dtds/podcast-1.0.dtd');
$feed->set('itunes,title', 'My Show'); // comma
$item->addElement('itunes:category', 'Education'); // colon
$media = new \FeedIo\Feed\Item\Media;
$media->setUrl('https://cdn.example.com/ep1.mp3');
$media->setType('audio/mpeg');
$media->setLength(filesize($path));
$item->addMedia($media); // removeMedia() exists since 6.3
Discovery as a first-class endpoint. $feedIo->discover($url) returns an array of feed URLs from page headers — good for an "import from URL" onboarding flow. Wrap it, since it makes a live HTTP request and can throw.
Testing: inject a fake FeedIo\Adapter\ClientInterface that returns a fixture body (store a handful of real RSS/Atom/JSONFeed samples in tests/fixtures). Don't hit the network in the suite, and don't rely on Http::fake() — feed-io doesn't use Laravel's HTTP client.
Laravel's Log facade is not PSR-3. new FeedIo($client, Log::getFacadeRoot()) will fail. Resolve Psr\Log\LoggerInterface from the container, or use new \Psr\Log\NullLogger() if you don't want feed-io's parse diagnostics in your logs.
getDateTimeBuilder()->setFeedTimezone() is stateful on the instance. Feeds with timezone-less dates need it, but it leaks into every subsequent read on the same FeedIo object. If you're on Octane/queue workers holding a singleton, wrap it in try/finally with resetFeedTimezone(), or bind FeedIo as scoped instead of singleton:
$feedIo->getDateTimeBuilder()->setFeedTimezone(new \DateTimeZone('Europe/Paris'));
try {
$result = $feedIo->read($url);
} finally {
$feedIo->getDateTimeBuilder()->resetFeedTimezone();
}
feed-io does not persist ETag/Last-Modified for you. There's no cache store — conditional requests only happen when you pass $modifiedSince to read(). If you skip that, you're downloading full feeds every poll.
Redirects are handled twice if you're not careful. Since 6.2 the adapter follows 301/302/303/307/308 with loop protection and relative-URL support. Guzzle follows redirects by default too, which means the feed-io handler may never see them. Pick one: either new Guzzle(['allow_redirects' => false]) and let feed-io normalize the final URL, or leave Guzzle's redirects on and accept that $result->getUrl() reflects the original request.
Expect exceptions, not null. Malformed XML, invalid JSON (hardened in 6.3.0), non-feed HTML, 404s, and timeouts all throw. Catch \FeedIo\Reader\ReadErrorException (and adapter-level HTTP exceptions) per source and record the failure — one dead feed must never abort the whole polling batch.
Don't double-escape HTML in descriptions. feed-io handles RSS encoding/CDATA itself. Passing e($post->excerpt) into setDescription() yields visible <p> in readers. Store/emit the HTML as-is; sanitize it upstream.
Relative links survive parsing but may be meaningless to you. 6.1 fixed relative-URL analysis for Atom and trimmed whitespace from link properties, but if you're re-serving parsed items on your own domain, normalize with url($item->getLink()) — a bare /posts/foo will resolve against your host, not the source's.
Memory grows with feed size. The parsers build a DOM/array in memory; a 500-item feed with full content is fine, a 5,000-item archive feed is not. Feed it to the queue with a raised memory_limit, or cap items at ingest and let the source URL do the paging.
Version pinning matters more than usual here. Zero dependents in the wild means fewer eyes; the changelog shows behavior-changing fixes in patches-adjacent minors (link resolution, next-update computation, redirect handling). Pin ^6.3 and read changelog/ before bumping — the getNextUpdate() algorithm changed materially in 6.2.
Extension points, in order of usefulness: the PSR-18/P adapter + Guzzle options (timeouts, proxies, retries), custom FeedIo\Filter implementations for dedupe rules, setNS() + addElement() for any namespaced RSS extension (Podcasting 2.0, Media RSS), and Item\Media for enclosures. If you need conditional caching against your own DB, that lives in your job — not in the library.
How can I help you explore Laravel packages today?