Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Feed Io Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

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 &lt;p&gt; 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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator