Availability
The publisher goes down
A fetch without a timeout hangs the dashboard request behind it. Requests time out in 8 seconds, retry twice, and a failure serves the cached copy labelled honestly as stale.
Laravel Nova · dashboard card
One card class and a source key. The feed arrives cached, cleaned, sorted and limited, in one of four layouts — with no controller, no route file and no published assets on your side.
app/Nova/Dashboards/Main.php
use Gabrielesbaiz\NovaCardRssNews\Cards\RssNewsCard;
public function cards(): array
{
return [
RssNewsCard::make()->source('laravel_news'),
];
}
No controller, no route file, no published assets
01 · Try it
Every combination below is a real call with its real result. Pick a card class, then a layout, and the code and the rendering change together.
arrow keys step through · RssNewsCard, hero layout
What renders
02 · The problem
Every one of these is a bug somebody shipped with eighty lines of their own, and every one of them is handled here.
Availability
A fetch without a timeout hangs the dashboard request behind it. Requests time out in 8 seconds, retry twice, and a failure serves the cached copy labelled honestly as stale.
Dialects
RSS 2.0, Atom, RDF and JSON Feed, detected by content because publishers get content types wrong. Everything lands in one item shape with an ISO-8601 date.
Safety
Markup is stripped and entities decoded server side. URLs are never accepted from the client — an ad-hoc feed is encrypted into the card's meta with your app key.
03 · How a read works
A card asks for a feed. Before any network call happens, the cache decides which of these five it is — so the slow path is the exception, not the rule.
Younger than cache.ttl, 300 seconds by default. Returned as is — nothing leaves your network.
The old copy goes back immediately and a refresh runs behind it — queued when queue.enabled, inline otherwise. Only the first request past the TTL queues a job.
ETag and Last-Modified go back as conditional headers. A 304 extends the TTL without downloading or re-parsing anything.
The cached items are served with stale: true and the card says "Showing cached copy". A dead publisher never blanks a dashboard.
The only case that waits on the network, and the only one that can fail: 502, and the card offers a retry button rather than an empty list.
Schedule nova-rss:warm every five minutes and every read is a cache hit. With queue.enabled, even a stale one never waits.
# GET /nova-vendor/nova-card-rss-news/feed?source=laravel_news&limit=1
{
"key": "laravel_news",
"fetched_at": "2026-09-25T18:04:11+00:00",
"stale": false, // state 01 above
"parser": "rss2",
"items": [{
"id": "b1946ac92492d2347c6235b4d2611184",
"title": "Laravel 12 released",
"published_at": "2026-09-25T10:00:00+00:00",
"source_key": "laravel_news"
}]
}
$ php artisan nova-rss:check
Status Source Parser Items Time URL / error
ok bbc_world rss2 34 241 ms https://feeds.bbci.co.uk/…
ok laravel_news rss2 20 118 ms https://feed.laravel-news.com/
fail old_blog - 0 5003 ms HTTP 404
Exits non-zero on any failure, so it belongs in CI — a moved feed is found before a user finds it.
Support this package
When this works, you never think about it. You do not see the publisher that started returning 403 to unknown user agents, the Atom feed that moved, the news site whose dates went non-standard, or the Nova release that changed how cards receive their meta. You see headlines on a dashboard. That invisibility is the work. It stays MIT either way.
Sponsorship buys the boring half: keeping the presets alive, the suite green across new PHP, Laravel and Nova majors, and answering issues from people running this in production. It does not buy features, and it does not unlock anything. It stays MIT either way.
Company tiers get a logo in the README and on this page.
Thirty seconds, and it is the first signal other developers look at.
A feed URL and what you expected is worth more than you think — dead feeds are found by users first.
Monthly, cancel anytime. It pays for the maintenance nobody sees.
If this runs on your team's dashboards, your logo goes in the README and here.
Why ask at all? This package is MIT and always will be. Nothing is paywalled, nothing phones home, and no feed, preset or layout is held back for sponsors. Sponsorship buys maintenance time, not features — and if you cannot sponsor, the star and the bug report genuinely help.
What the package is, when to reach for it, and the trade it makes.
A Laravel Nova dashboard card that reads RSS, Atom, RDF and JSON Feed, normalizes all four into one item shape, caches the result, and renders it in one of four layouts. Three cards ship: one pinned to a feed, one with a picker, and one that merges many feeds into a single chronological stream.
Nothing is Nova-specific below the surface. The feed pipeline — FeedManager, the parsers, the source catalogue — is a plain service you can call from a Blade view, a notification or a report.
use Gabrielesbaiz\NovaCardRssNews\Cards\RssNewsCard;
public function cards(): array
{
return [
RssNewsCard::make()->source('laravel_news'),
];
}If the items already live in your database, you do not need this package. A Nova card with a Vue file and a controller is about eighty lines, has no dependencies, and nothing ever surprises you.
You want this package when the data lives on somebody else's server. That is where the eighty lines turn into: a file_get_contents that hangs the dashboard when a publisher goes down; four incompatible dialects, three of which you discover in production; entity-encoded titles; <img> tags in summaries; timestamps in five formats; a cache that has to serve something when the upstream is 503; and a refresh button that must not become a way to hammer a news site from inside your admin panel.
This package is those problems, solved and tested. The cards are the small part.
Your data · write it yourself
class LatestPosts extends Card
{
public function component(): string
{
return 'latest-posts';
}
}
// + one Vue file, + one controller.
// About eighty lines. No dependency.
Remote feeds · install this
RssNewsCard::make()->source('laravel_news');
// timeouts + retries + a real user agent
// 4 dialects, detected by content
// entities decoded, markup stripped
// ETag revalidation, stale-while-revalidate
// SSRF-safe URLs, two rate limits
// 89 tests covering all of it
It is opinionated about the payload. Every dialect is normalized to one flat item — title, link, summary, date, image, author, categories — and anything a dialect carries beyond that is dropped before it reaches the browser. If you need the raw <item> with its custom namespaces intact, use a plain reader such as willvincent/laravel-feed-reader and render it yourself.
There is also a genuine privacy cost to favicons: the only reliable way to resolve them without shipping an icon set is Google's S2 service, and that tells Google which feeds your dashboard reads. They are on by default because most dashboards want them; ui.favicons => 'none' turns them off and draws initials from the feed title instead.
Requirements, the composer line, what to publish and what works untouched.
| Requirement | Version |
|---|---|
| PHP | ^8.2 |
| Laravel | ^11.0, ^12.0 or ^13.0 — Laravel 13 needs PHP 8.3 |
| Laravel Nova | ^5.0 — required by composer, licensed separately by Laravel |
| Extensions | ext-simplexml, bundled with every standard PHP build |
composer require gabrielesbaiz/nova-card-rss-news
The card's JavaScript and CSS are served by the package's own service provider. There is nothing to publish, build or add to your vite.config.js.
Publish the config when you want to change the feed catalogue or any default:
php artisan vendor:publish --tag="nova-card-rss-news-config"
Publish the translations to edit the interface strings:
php artisan vendor:publish --tag="nova-card-rss-news-translations"
Neither is required. The defaults render a working card with real news.
Add a card to any dashboard or resource:
use Gabrielesbaiz\NovaCardRssNews\Cards\RssNewsCard;
RssNewsCard::make()
->source('laravel_news')
->limit(8)
->width('1/3');
Three variations you are likely to want next — a pinned card, a user-switchable one, and everything in one stream:
use Gabrielesbaiz\NovaCardRssNews\Cards\RssNewsCard;
use Gabrielesbaiz\NovaCardRssNews\Cards\RssNewsSelectCard;
use Gabrielesbaiz\NovaCardRssNews\Cards\RssNewsStreamCard;
public function cards(): array
{
return [
// Pinned to one feed, compact, refreshing every five minutes.
RssNewsCard::make()
->source('laravel_news')
->layout('compact')
->limit(8)
->autoRefresh(300)
->width('1/3'),
// User-switchable: the reader picks the feed, the choice is remembered.
RssNewsSelectCard::make()
->defaultSource('hacker_news')
->categories(['technology', 'development'])
->width('1/3'),
// Everything in one chronological stream, searchable.
RssNewsStreamCard::make()
->categories(['world', 'technology'])
->layout('grid')
->searchable()
->limit(24),
];
}
Then confirm every feed in your catalogue answers:
php artisan nova-rss:check
Status Source Parser Items Time URL / error
ok bbc_world rss2 34 241 ms https://feeds.bbci.co.uk/news/world/rss.xml
ok laravel_news rss2 20 118 ms https://feed.laravel-news.com/Every key in config/nova-card-rss-news.php, its default, and what changing it does.
return [
'sources' => [Presets\StarterPreset::class],
'http' => [
'timeout' => 8,
'connect_timeout' => 3,
'retries' => 2,
'retry_delay' => 200,
'max_redirects' => 5,
'verify' => true,
'user_agent' => 'Mozilla/5.0 (compatible; NovaCardRssNews/3.0; …)',
],
'cache' => [
'store' => null,
'ttl' => 300,
'stale_ttl' => 3600,
'prefix' => 'nova-card-rss-news',
],
'queue' => ['enabled' => false, 'connection' => null, 'queue' => null],
'routes' => [
'middleware' => ['nova'],
'authorize' => null,
'throttle' => '120,1',
'fresh_throttle' => '20,1',
],
'ui' => ['favicons' => 'none', 'images' => true],
'defaults' => [
'limit' => 10,
'layout' => 'hero',
'auto_refresh' => 0,
'search' => false,
'read_state' => true,
],
];| Key | Default | Effect |
|---|---|---|
http.timeout | 8 | Seconds before a feed request is abandoned. The ceiling on how long a dashboard can wait. |
http.connect_timeout | 3 | Seconds to establish the connection. |
http.retries | 2 | Attempts before a fetch is declared failed. |
http.retry_delay | 200 | Milliseconds between attempts. |
http.max_redirects | 5 | Redirect hops followed. |
http.verify | true | TLS verification. Turn off only for an internal feed with a self-signed certificate. |
http.user_agent | see above | Some publishers 403 unknown agents; some allowlist specific ones. |
| Key | Default | Effect |
|---|---|---|
cache.store | null | Cache store to use; null is the app default. |
cache.ttl | 300 | Seconds a feed counts as fresh. |
cache.stale_ttl | 3600 | Extra seconds a stale copy stays usable when upstream fails. |
cache.prefix | nova-card-rss-news | Cache key prefix. |
queue.enabled | false | Refresh stale feeds in a job instead of inline. |
queue.connection / queue.queue | null | Where those jobs go. |
| Key | Default | Effect |
|---|---|---|
routes.middleware | ['nova'] | Middleware on all three endpoints. |
routes.authorize | null | A gate ability or closure; null allows any Nova user. |
routes.throttle | '120,1' | Budget for cached reads, attempts,minutes. |
routes.fresh_throttle | '20,1' | Budget for ?fresh=1 reads, which reach upstream. |
| Key | Default | Effect |
|---|---|---|
ui.favicons | 'google' | Resolves site icons via Google's S2 service, which tells Google which feeds you read. 'none' draws initials locally and makes no third-party request. |
ui.images | true | Default for images(). |
defaults.limit | 10 | Default for limit(). |
defaults.layout | 'hero' | Default for layout(). |
defaults.auto_refresh | 0 | Default for autoRefresh(); 0 disables. |
defaults.search | false | Default for searchable(). |
defaults.read_state | true | Default for readState(). |
The three cards, the four layouts and every option, running in this page. Nothing here is a screenshot — click an item to mark it read, type in the search box, switch the source, break the feed and watch what the card does about it.
Every control is one method on the card class. The code underneath updates as you go, and it is the code you would paste into a dashboard.
The card, as PHP
The rendering, the options and the state machine match. Three things cannot be simulated honestly in a static page, and are faked deliberately:
| Here | In your app |
|---|---|
| Fixture items in the page | A real HTTP fetch through FeedManager, cached in your cache store |
| Thumbnails drawn as SVG | media:content, media:thumbnail or an image <enclosure> from the feed |
| Refresh invents one new item | ?fresh=1 re-fetches upstream, subject to routes.fresh_throttle |
Everything else — server-side limit, newest-first sort, dedup by item id, entity-decoded titles, the five cache outcomes, read and bookmark state — is the same behaviour the test suite covers.
Three cards, four layouts, and the reading options they share. Every option on this page is switchable in the playground.
One card, one feed, chosen in PHP. source() takes a catalogue key, not a URL — run nova-rss:export to see every key available.
RssNewsCard::make()
->source('bbc_world')
->limit(6)
->heading('World news')
->width('1/2');
The same card with a picker in the header. The selection persists in localStorage, keyed by component and default source, so two pickers on one dashboard never overwrite each other's choice. The picker groups sources into <optgroup> elements by category; categories() restricts it to the keys you name, and leaving it out offers the whole catalogue.
RssNewsSelectCard::make()
->defaultSource('laravel_news')
->categories(['development', 'world'])
->remember(false)
->limit(12);
Several feeds merged into one chronological stream. Items carry a source badge, duplicates across feeds are dropped, and a feed that fails is skipped rather than taking the card down.
RssNewsStreamCard::make()
->sources(['bbc_world', 'npr_news'])
->categories(['technology'])
->limit(30)
->layout('grid')
->width('full');
sources() and categories() are additive: the stream is the union of both.
RssNewsCard::make()->source('the_verge')->layout('grid');
| Layout | Shape | Good for |
|---|---|---|
hero | A featured first item with image and summary, then a compact list | A single feed on a half-width card |
compact | A dense one-line-per-item list, no hero | Sidebars, 1/3 cards, long lists |
grid | Responsive cards with thumbnails | Full-width cards, image-heavy feeds |
ticker | A single horizontal marquee, paused on hover | A status strip along the top of a dashboard |
Change the default for every card at once with defaults.layout.
searchable() adds a search toggle to the header. Filtering happens in the browser across title, summary and source name — no request per keystroke — and matches are highlighted. The term resets when the feed changes.
images() shows an image when the feed provides one: media:content, then media:thumbnail, then an image <enclosure>, then the first <img> in the description. Nothing is proxied or re-hosted; a broken image simply disappears.
Opened items are dimmed and the header badges how many are new since the last visit. Each item gets a bookmark toggle on hover. Both live in localStorage, per card and per browser — nothing is written to your database, and nothing is shared between users. The read list is capped at 400 ids per card.
RssNewsCard::make()->source('ansa_top_news')->autoRefresh(300);
The interval pauses while the tab is hidden and while the pointer is over the card — a list that reshuffles under the cursor mid-read is worse than a stale one. Auto refreshes read through the cache; they do not hammer upstream.
On by default: each card shows the publisher's site icon, resolved through Google's S2 service. That tells Google which feeds your dashboard reads, so set ui.favicons => 'none' to draw initials locally instead and make no third-party request.
feed() points a card at a URL that is not in the catalogue:
RssNewsCard::make()
->feed('https://example.com/blog/feed.xml', 'Company blog')
->limit(5);
The URL is encrypted into the card's meta with your application key, and the endpoint accepts only that encrypted form. A client cannot hand the server an arbitrary URL to fetch, which would otherwise turn every dashboard into an SSRF proxy into your private network.
Use this for one-off feeds. For anything a second card might want, put it in the catalogue instead — you get caching keyed by source, nova-rss:check coverage and a picker entry.
Presets, your own feeds, providers, and per-user catalogues.
A source is a feed with a key, a title and a category. The catalogue is the merge of every entry in the sources config array, in order, with later entries overriding earlier ones by key. An entry may be a preset class, any class implementing SourceProvider, a closure, or an inline array.
use Gabrielesbaiz\NovaCardRssNews\Presets;
'sources' => [
Presets\StarterPreset::class, // shipped preset
App\Feeds\TeamFeeds::class, // your own provider
fn () => auth()->user()?->rssFeeds, // per-user, evaluated per request
['categories' => [/* … */]], // inline, the v2 shape
],| Preset | Contents |
|---|---|
StarterPreset | Enabled by default. BBC World, NPR, Al Jazeera, Hacker News, The Verge, Ars Technica, Laravel News, PHP.Watch, The GitHub Blog |
ItNewsPreset | Italian nationals and wires: ANSA (six feeds), Repubblica (four), Corriere, La Stampa, Il Fatto Quotidiano, Panorama |
ItInsurancePreset | Assinews, InsuranceUp, Intermedia Channel, IVASS, ANIA, FIRSTonline Assicurazioni, Il Sole 24 ORE Finanza |
ItMotoriPreset | Quattroruote, Alvolante, Motor1, Motori.it, ANSA Motori, Corriere Motori, Il Sole 24 ORE Motori |
ItEconomyPreset | Il Sole 24 ORE: Finanza, Italia, Norme & Tributi, Risparmio |
ItTravelPreset | MasterViaggi (nine sections), SiViaggia, TravelQuotidiano, Nonsoloturisti |
ItSportPreset | Gazzetta dello Sport |
AggregatorsPreset | Google News Italia |
A preset is a class with a static array, so you can extend one to add a feed:
class OurNewsroom extends ItNewsPreset
{
public static function categories(): array
{
return array_merge(parent::categories(), [
'internal' => [
'label' => 'Internal',
'sources' => [
'newsroom' => ['title' => 'Newsroom', 'url' => 'https://intranet.example/feed'],
],
],
]);
}
}'sources' => [
Presets\StarterPreset::class,
[
'categories' => [
'team' => [
'label' => 'Team',
'sources' => [
'company_blog' => ['title' => 'Company blog', 'url' => 'https://example.com/feed'],
'status' => [
'title' => 'Status page',
'url' => 'https://status.example.com/history.atom',
'ttl' => 60, // this one is time-sensitive
],
'archive' => [
'title' => 'Archive',
'url' => 'https://example.com/old/feed',
'enabled' => false, // keep the entry, hide the feed
],
],
],
],
],
],
Per-source keys: title, url, site_url, ttl, enabled, parser, category, category_label.
use Gabrielesbaiz\NovaCardRssNews\Contracts\SourceProvider;
use Gabrielesbaiz\NovaCardRssNews\Data\Source;
use Illuminate\Support\Collection;
class DatabaseFeeds implements SourceProvider
{
public function sources(): Collection
{
return Feed::query()->where('active', true)->get()
->map(fn (Feed $feed): Source => new Source(
key: $feed->slug,
title: $feed->name,
url: $feed->url,
categoryKey: $feed->category,
ttl: $feed->ttl,
));
}
}
Providers are resolved through the container, so constructor injection works.
A closure is re-evaluated per request, which is what makes per-user catalogues possible without the package owning a migration:
'sources' => [
Presets\StarterPreset::class,
fn () => auth()->user()?->rssFeeds->map(fn ($feed) => new Source(
key: "user_{$feed->id}",
title: $feed->title,
url: $feed->url,
categoryKey: 'my_feeds',
)) ?? [],
],
A category needs no declaration; it exists because sources point at it. A label may be a literal string, or a translation key resolved through your lang files:
'label' => 'nova-card-rss-news::sources.team',
The shipped presets use translation keys, which is why the starter categories read "World" in English and "Mondo" in Italian. Unresolvable keys fall back to a humanized version of the key rather than printing the raw key.
How a read resolves, and the four knobs that change it.
Every read goes through FeedManager:
cache.ttl (300s) — returned as is.ttl + stale_ttl — the stale copy is returned immediately and a refresh runs: queued if queue.enabled, inline otherwise.fresh=1 — fetched synchronously.stale: true, and the card shows "Showing cached copy".502 and the card offers a retry button.ETag and Last-Modified are stored with each cached feed and replayed as If-None-Match / If-Modified-Since on every refresh. A 304 extends the TTL without re-downloading or re-parsing anything — most refreshes cost a few hundred bytes.
'queue' => [
'enabled' => true,
'connection' => null,
'queue' => 'feeds',
],
With the queue enabled, a soft-expired feed is refreshed by a RefreshFeed job while the viewer gets the cached copy instantly. Only the first request past the TTL queues a job — the rest are deduplicated by an atomic cache lock — so a dashboard with ten cards does not queue ten refreshes.
php artisan nova-rss:warm
Fetches every source so the next dashboard load is a cache hit. Scheduled, no user ever waits on an upstream:
// routes/console.php
Schedule::command('nova-rss:warm')->everyFiveMinutes();
A warm-up runs against publishers you do not control, and one of them timing out does not mean the cache is cold. The command exits zero as long as at least one source warmed, and fails only when every source is down — which points at your network or configuration rather than at a publisher. Each source it could not warm is written to the log with its key and the error, because a scheduler keeps the exit code and throws the output away.
php artisan nova-rss:warm --strict # fail on any single source, for a pipeline that wants thatSix artisan commands: health, warming, OPML in and out, and discovery.
| Command | What it does |
|---|---|
nova-rss:list [--keys] [--calls] [--category=*] [--search=] | List the source keys available to source() and defaultSource(). |
nova-rss:check [--source=*] [--category=*] | Fetches every source and prints status, parser, item count and latency. Exits non-zero if any fail — CI-friendly. |
nova-rss:warm [--source=*] [--category=*] [--strict] | Pre-fetches every source into the cache. Exits zero as long as one source warmed, and names each failure in the log; --strict fails on any single one. |
nova-rss:discover <url> | Finds the feed behind a website URL and prints a config line. |
nova-rss:import <file> [--category=] [--validate] | Turns an OPML export into a pastable config block. |
nova-rss:export [--output=] | Writes the resolved catalogue as OPML. |
source() and defaultSource() take a catalogue key, and the keys come from whichever presets and providers you enabled. This prints them.
$ php artisan nova-rss:list --search=ansa
Key Title Category
ansa_all ANSA Agenzie di stampa
ansa_cronaca ANSA Cronaca Agenzie di stampa
ansa_motori ANSA Motori Motori
Two shapes are meant for pasting rather than reading:
$ php artisan nova-rss:list --keys # bare keys, one per line
$ php artisan nova-rss:list --calls # ready-to-paste card calls
->defaultSource('ansa_all') // ANSA
->defaultSource('ansa_cronaca') // ANSA Cronaca
--method=source writes ->source() calls for the fixed card instead, --urls adds the feed URL to the table, --json emits the catalogue for scripting, and --category / --search narrow the list.
php artisan nova-rss:check
Status Source Parser Items Time URL / error
ok bbc_world rss2 34 241 ms https://feeds.bbci.co.uk/news/world/rss.xml
ok laravel_news rss2 20 118 ms https://feed.laravel-news.com/
fail old_blog - 0 5003 ms Feed [https://old.example/feed] responded with HTTP 404.
It exits non-zero on any failure, so it belongs in CI. This is the command that catches a publisher moving its feed before a user does.
OPML is what every feed reader exports — Feedly, Inoreader, NetNewsWire, all of them. Point the command at one and it prints a config block:
php artisan nova-rss:import ~/Downloads/feedly.opml
[
'categories' => [
'tech_news' => [
'label' => 'Tech News',
'sources' => [
'the_verge' => ['title' => 'The Verge', 'url' => 'https://www.theverge.com/rss/index.xml'],
],
],
],
],
Nested outlines become categories; keys are slugified from each title. Two useful options:
php artisan nova-rss:import feeds.opml --category=imported # flatten into one category
php artisan nova-rss:import feeds.opml --validate # fetch each feed, drop the dead ones
Export goes the other way, and is the fastest way to list every key available to source(). Without --output it prints to stdout, so it pipes:
php artisan nova-rss:export --output=feeds.opmlYou rarely know a feed's URL; you know the site's:
php artisan nova-rss:discover https://laravel-news.com
Feed URL Title Format Items
https://feed.laravel-news.com/ Laravel News rss2 20
'laravel_news' => ['title' => 'Laravel News', 'url' => 'https://feed.laravel-news.com/'],
It reads <link rel="alternate"> from the page head, and falls back to probing /feed, /feed/, /rss, /rss.xml, /atom.xml, /index.xml and /feed.json. Every candidate is fetched and parsed before it is reported, so a result is a feed that actually works.
Whole solutions to the tasks that actually come up.
# 1. Export OPML from your reader, then import it — validating as you go,
# which drops feeds that died while you were not looking.
php artisan nova-rss:import ~/Downloads/feedly.opml --validate
# 2. Paste the printed block into config/nova-card-rss-news.php under 'sources'.
# 3. Confirm every feed is healthy and see which parser each one uses.
php artisan nova-rss:check
--validate fetches each feed before printing it, so what lands in your config is a list that works today, not a list that worked when you subscribed.
RssNewsStreamCard::make()
->categories(['wires'])
->layout('ticker')
->limit(25)
->autoRefresh(120)
->heading('Wires')
->width('full'),
RssNewsStreamCard::make()
->categories(['world', 'economy'])
->layout('grid')
->searchable()
->images()
->limit(24)
->width('full'),
The ticker pauses on hover so a headline can be read, and both cards share one 30-second clock rather than running a timer each.
'cache' => ['ttl' => 300, 'stale_ttl' => 86400],
'queue' => ['enabled' => true, 'queue' => 'feeds'],
Schedule::command('nova-rss:warm')->everyFiveMinutes();
The scheduler keeps every feed warm; a soft-expired feed refreshes in a job while the viewer gets the cached copy immediately; and a full day of stale_ttl means even a long outage leaves the dashboard populated and honestly labelled.
use Gabrielesbaiz\NovaCardRssNews\Events\FeedFetchFailed;
Event::listen(FeedFetchFailed::class, function (FeedFetchFailed $event): void {
if ($event->servedStale) {
return; // the reader saw something; not worth waking anyone
}
Notification::route('slack', config('services.slack.ops'))
->notify(new FeedDown($event->source->key, $event->exception->getMessage()));
});
Pair it with php artisan nova-rss:check in CI to catch a dead feed before a user does.
// config/nova-card-rss-news.php
'sources' => [
Presets\StarterPreset::class,
fn () => auth()->user()?->rssFeeds->map(fn ($feed) => new Source(
key: "user_{$feed->id}",
title: $feed->title,
url: $feed->url,
categoryKey: 'my_feeds',
categoryLabel: 'My feeds',
)) ?? [],
],
RssNewsSelectCard::make()->categories(['my_feeds']);
The closure runs per request, so a feed added through your own Nova resource appears in the picker on the next page load. Give users an RssFeed resource with a URL field, and wire nova-rss:discover into a Nova action if you want them to add feeds by site URL rather than by feed URL.
RssNewsCard::make()
->feed('https://intranet.example/releases.atom', 'Releases')
->layout('compact')
->limit(6)
->width('1/3');
The URL is encrypted into the card's meta with your application key, so the endpoint will never fetch a host a client asked for. Use this for one-off feeds; anything a second card might want belongs in the catalogue instead.
'http' => ['verify' => false] applies to every feed — so prefer fixing the certificate, or put the feed behind a proxy that terminates TLS properly.// AuthServiceProvider
Gate::define('viewRssNews', fn ($user): bool => $user->hasRole('editor'));
// config/nova-card-rss-news.php
'routes' => ['authorize' => 'viewRssNews'],
// the dashboard
RssNewsCard::make()->source('laravel_news')->canSee(
fn ($request): bool => $request->user()->can('viewRssNews'),
);
canSee() hides the card; the gate protects the endpoints. You want both — a hidden card is not an access control.
Card methods, the services behind them, the events and the endpoints.
Shared by all three cards:
| Method | What it does | Default |
|---|---|---|
limit(int $limit) | Items to fetch and render; applied server side | 10 (20 for the stream card) |
layout(string $layout) | hero, compact, grid or ticker | hero |
images(bool $images = true) | Show thumbnails when the feed has them | ui.images |
autoRefresh(int $seconds) | Re-fetch on an interval; 0 disables | 0 |
searchable(bool $on = true) | Render the search toggle | false |
readState(bool $on = true) | Track read / bookmarked items in the browser | true |
heading(string $heading) | Override the card heading | the feed's title |
feed(string $url, ?string $title, ?string $key) | Use a URL outside the catalogue, encrypted into the meta | — |
width(string $width) | Nova card width: 1/3, 1/2, full | 1/2 (full for the stream card) |
| Card | Method | What it does |
|---|---|---|
RssNewsCard | source(string $key) | The catalogue key to display |
RssNewsSelectCard | defaultSource(string $key) | Selected on first visit |
categories(array $categories) | Restrict the picker to these categories | |
remember(bool $remember = true) | Persist the choice in localStorage | |
RssNewsStreamCard | sources(array $keys) | Explicit source keys to merge |
categories(array $categories) | Merge every source in these categories |
use Gabrielesbaiz\NovaCardRssNews\Feeds\FeedManager;
use Gabrielesbaiz\NovaCardRssNews\Sources\SourceRepository;
$sources = app(SourceRepository::class);
$feeds = app(FeedManager::class);
$feed = $feeds->get($sources->findOrFail('laravel_news'), limit: 5);
$feed->title; // 'Laravel News'
$feed->stale; // false
$feed->fetchedAt; // CarbonImmutable
$feed->items[0]->title; // 'Laravel 12 released'
$feed->toArray(); // the JSON payload
$feeds->stream($sources->inCategories(['technology']), limit: 20);
$feeds->get($source, fresh: true); // ignore the cache
$feeds->refresh($source); // fetch and re-cache
$feeds->forget($source); // drop one feed from the cache
$feeds->cacheKey($source); // the key it uses$sources->all(); // Collection<string, Source>, keyed by source key
$sources->find('laravel_news'); // ?Source
$sources->findOrFail('laravel_news'); // Source, throws SourceNotFound
$sources->only(['bbc_world', 'npr_news']);
$sources->inCategories(['world']);
$sources->grouped(); // the array the picker renders
$sources->extend(NewProvider::class); // add a provider at runtime
$sources->flush(); // forget the resolved catalogue
extend() is the seam for tests and for packages that contribute feeds from their own service provider.
use Gabrielesbaiz\NovaCardRssNews\Events\FeedFetched;
use Gabrielesbaiz\NovaCardRssNews\Events\FeedFetchFailed;
Event::listen(FeedFetched::class, function (FeedFetched $event): void {
$event->source->key; // 'laravel_news'
$event->feed->items; // FeedItem[]
$event->durationMs; // 143.28
$event->notModified; // true when the upstream answered 304
});
Event::listen(FeedFetchFailed::class, function (FeedFetchFailed $event): void {
$event->exception; // FeedUnreachable | UnsupportedFeedFormat
$event->servedStale; // true when the reader still got a cached copy
});Every route already requires an authenticated Nova user. To narrow it further:
'routes' => [
'middleware' => ['nova'],
'authorize' => 'viewRssNews', // a Gate ability…
// 'authorize' => fn ($request) => $request->user()->isAdmin(),
'throttle' => '120,1', // normal, cached reads
'fresh_throttle' => '20,1', // ?fresh=1, which hits upstream
],
The two budgets are separate on purpose. Cached reads are nearly free and should not be throttled aggressively; fresh=1 reaches out to a third party, and without its own much smaller budget the refresh button becomes a way to hammer a news site from inside your admin panel.
canSee() on the card itself. canSee() hides the card; the gate protects the endpoints — a hidden card is not an access control.All three live under nova-vendor/nova-card-rss-news and carry the nova middleware group plus whatever you add in routes.middleware.
| Method | Path | Query | Returns |
|---|---|---|---|
GET | /feed | source, feed, limit, fresh | One normalized feed |
GET | /stream | sources[], categories[], limit, fresh | Several feeds merged |
GET | /sources | categories[] | The catalogue, grouped for the picker |
Status codes: 404 for an unknown source or an invalid encrypted feed, 422 for a malformed query, 429 when the refresh budget is spent, 502 when the upstream failed and nothing was cached.
The four dialects, how they are detected, and the shape everything becomes.
| Format | Detected by | Notes |
|---|---|---|
| RSS 2.0 | <rss> root | Reads content:encoded, dc:creator, dc:date, <category>, media:*, <enclosure> |
| Atom 1.0 | <feed> root | Prefers rel="alternate" links; <summary> then <content>; <published> then <updated> |
| RSS 1.0 / RDF | <rdf:RDF> root | Dublin Core dates and authors |
| JSON Feed 1.x | A JSON object with items | content_text and content_html, tags, authors |
Detection is by content, not by extension or content type — publishers get both wrong routinely. Force a parser for a stubborn feed with the per-source 'parser' => 'atom' key.
{
"id": "b1946ac92492d2347c6235b4d2611184",
"title": "Laravel 12 released",
"link": "https://example.com/news/1",
"summary": "Plain text, entities decoded, markup stripped, 400 chars.",
"published_at": "2026-06-29T10:00:00+00:00",
"image_url": "https://example.com/img/1.jpg",
"author": "Taylor Otwell",
"categories": ["releases"],
"source_key": "laravel_news",
"source_title": "Laravel News",
"source_site": "https://laravel-news.com"
}
The envelope adds key, title, url, feed_url, fetched_at, stale and parser.
Titles and summaries are entity-decoded and stripped of markup server side, so nothing from a feed is ever rendered as HTML in your admin panel. Summaries are trimmed to 400 characters on a word boundary.
Items are sorted newest first, deduplicated by id (a hash of the link, or the title when a feed omits links), and cut to the requested limit before they are serialized. The client receives exactly what it renders.
Dates are parsed leniently — RFC 822, RFC 3339, and whatever else a publisher invented — and emitted as ISO-8601 in UTC. A date that cannot be parsed becomes null rather than today.
The failures people actually hit, and what each one means.
php artisan nova-rss:check --source=my_feed
Three common answers:
http.user_agent.nova-rss:discover <site-url> to find the real one.ok — the feed is genuinely empty, or it is a format variant. Force the parser with 'parser' => 'atom' on the source.In 2.x it truly did nothing for five minutes: it only busted the browser cache while the server kept returning its cached copy. In 3.0 refresh sends ?fresh=1, which bypasses the server cache.
If it still seems inert, watch for a 429: routes.fresh_throttle defaults to 20 refreshes a minute per user, and the card surfaces "Too many refreshes".
http.timeout is 8 seconds by default, deliberately: it is the ceiling on how long a dashboard request can hang. Raise it if the publisher is genuinely slow, but the better fix is to stop fetching in the request at all:
'queue' => ['enabled' => true],
Schedule::command('nova-rss:warm')->everyFiveMinutes();The upstream fetch failed and a cached copy was served instead. This is the package working as designed, not an error. nova-rss:check tells you why, and FeedFetchFailed fires on every occurrence if you want it logged.
Once cache.ttl + cache.stale_ttl has passed with no successful fetch, the cached copy is dropped and the card shows an error with a retry button.
They follow the Nova locale, not the app locale: whatever Nova.config('locale') reports is what Intl.RelativeTimeFormat receives. Interface strings come from the bundled JSON translations; a locale the package does not ship falls back to English.
They are opt-in now. Add the presets you were using:
'sources' => [
Presets\ItNewsPreset::class,
Presets\ItInsurancePreset::class,
Presets\ItMotoriPreset::class,
],
Every source key is unchanged, so ->source('motor1') keeps working once the preset that owns it is enabled.
They do not, as of 2.3.2: the localStorage key includes each card's defaultSource(), not just the component name.
Two pickers with the same default do share one key, which is usually what you want. Give them different defaults, or turn persistence off on one:
RssNewsSelectCard::make()->defaultSource('bbc_world'),
RssNewsSelectCard::make()->defaultSource('laravel_news')->remember(false),
Assets are registered by the package's service provider, so this is almost always a stale cached view or route:
php artisan optimize:clear
If you listed CardServiceProvider manually in config/app.php, replace it with NovaCardRssNewsServiceProvider — the old class still works, but it is deprecated.
The threat model, what is deliberate, and what remains the publisher's.
The card fetches URLs from a server inside your infrastructure and renders the result in an admin panel. Both halves of that are handled deliberately.
feed(). A crafted request cannot make the server fetch http://169.254.169.254/ or an internal host.<mark> around a search match, around already-escaped text.http.max_redirects, and TLS is verified by default.routes.authorize, on top of Nova's own authentication.ui.favicons => 'none' — turns them off and draws initials locally.Feeds are third-party content. A publisher can put anything in a title, and links point wherever they point. Everything renders as text, and every link carries rel="noopener", but deciding which publishers belong on an administration dashboard is the deploying application's call.
Read state and bookmarks live in the viewer's localStorage. Nothing about reading behaviour is written to your database or sent anywhere.
Email gabriele@sbaiz.com rather than opening a public issue. Include the version, the configuration involved, and a reproduction if you have one.
What breaks, what is shimmed, and the order to do it in.
The change most installations notice first. 2.x shipped an Italian news, insurance, motoring, travel and sport catalogue enabled by default. It is all still here, reclassified into presets, and no source key changed.
use Gabrielesbaiz\NovaCardRssNews\Presets;
'sources' => [
Presets\StarterPreset::class,
Presets\ItNewsPreset::class,
Presets\ItInsurancePreset::class,
Presets\ItMotoriPreset::class,
Presets\ItEconomyPreset::class,
Presets\ItTravelPreset::class,
Presets\ItSportPreset::class,
Presets\AggregatorsPreset::class,
],
Already customised your published config? Do not rewrite it — paste the whole categories array into sources as one inline entry; the v2 shape is still understood.
php artisan nova-rss:export | grep xmlUrl | wc -l
php artisan nova-rss:check| 2.x | 3.0 |
|---|---|
NovaCardRssNews | Cards\RssNewsCard |
NovaCardRssNewsSelect | Cards\RssNewsSelectCard |
CardServiceProvider | NovaCardRssNewsServiceProvider |
RssFeedService | Feeds\FeedManager (no alias) |
The old card and provider names remain as @deprecated subclasses, so dashboards keep working untouched. They are removed in 4.0.
$data = RssFeedService::getRssFeed('motor1');
$data['feed'][0]['pubDate'];
// 'Mon, 29 Jun 2026 10:00:00 +0200' — a raw, unsorted string$feed = app(FeedManager::class)->get(
app(SourceRepository::class)->findOrFail('motor1'),
limit: 5,
);
$feed->items[0]->publishedAt; // CarbonImmutable, newest first
$feed->stale; // true when a cached copy was served| 2.x | 3.0 |
|---|---|
GET …/news?source_key= | GET …/feed?source= |
| — | GET …/stream?sources[]=&categories[]= |
feed | items |
description | summary |
pubDate (raw, unsorted) | published_at (ISO-8601, newest first) |
| — | id, image_url, author, categories, source_*, stale, parser |
limit is now applied server side. This only matters if you called the endpoints yourself; the bundled Vue was rewritten with them.
PHP 8.2 and Laravel 11 are the floor. categories at the top level is replaced by sources; everything else — http, cache, queue, routes, ui, defaults — is new and optional.
One default changed on purpose: favicons. 2.x always used Google's favicon service; 3.0 defaults to none. Set ui.favicons back to 'google' if you prefer the icons.
Contributors only: Laravel Mix was replaced by Vite. Output paths are unchanged, and host applications are unaffected — the assets ship built.
php artisan optimize:clear
php artisan nova-rss:checkMirrors CHANGELOG.md. The current release in full, earlier ones summarised.
The nova-rss:warm changes below reached users in 3.1.1, which was tagged while they sat uncommitted in the working tree and whose notes described it as a documentation release. 3.1.1 is left in place — Packagist records the commit behind a published version and it cannot be moved — and this entry is where the work is documented.
nova-rss:warm no longer fails when some sources are unreachable. One third-party feed timing out failed the whole command, so a scheduled warm-up threw, the monitor logged a failure and the error tracker reported it — for a cache that was warm everywhere else. It now exits zero as long as at least one source warmed, and fails only when every source is down. nova-rss:check is unchanged: reporting health is what it is for.nova-rss:warm --strict restores the previous all-or-nothing behaviour, for a pipeline that wants any failure to stop it.nova-rss:warm changes documented under 3.2.0, which were committed by accident. Its original notes called it a documentation-only release; they were corrected after the fact.illuminate/support accepts ^13.0, and the test matrix runs Laravel 11, 12 and 13. Laravel 13 itself requires PHP 8.3, so that column is excluded on PHP 8.2; the package floor stays PHP 8.2 on Laravel 11 and 12.orchestra/testbench 11 are accepted in require-dev alongside Pest 3 and testbench 9 / 10.fixture() is now feed_fixture() — Pest 4 ships a global fixture() of its own. Tests only; no package code changed.A rewrite. The cards look the same; everything behind them is new.
StarterPreset ships enabled instead.config('nova-card-rss-news.categories') is replaced by sources, a list of providers. The v2 array is still accepted as an inline entry.news endpoint is now feed, with a normalized payload and the limit applied server side.Cards\RssNewsCard / Cards\RssNewsSelectCard; CardServiceProvider is now NovaCardRssNewsServiceProvider. Old names remain as deprecated aliases until 4.0.RssFeedService was removed in favour of the injectable FeedManager.none.RssNewsStreamCard: several feeds merged chronologically, badged by source, deduplicated, resilient to one feed failing.content:encoded, dc:creator, dc:date, media:content, media:thumbnail and image enclosures are read.SourceProvider classes, closures and inline arrays, merged by SourceRepository.nova-rss:check, nova-rss:warm, nova-rss:import, nova-rss:export and nova-rss:discover.FeedFetched and FeedFetchFailed events; ad-hoc feeds via feed() with the URL encrypted into the card meta.aria-live updates and prefers-reduced-motion support.file_get_contents had none of these, and a hung publisher hung the dashboard.ETag / Last-Modified revalidation and optional queued background refresh.<script setup> with composables and shared parts; Laravel Mix was replaced by Vite. Output paths are unchanged.| Version | Date | Headline |
|---|---|---|
| 2.4.0 | 2026-06-30 | Atom feed support; fixed Quattroruote and other Atom sources showing no news |
| 2.3.2 | 2026-06-29 | Fixed the localStorage collision between two picker cards on one dashboard |
| 2.3.1 | 2026-06-29 | Updated the IVASS feed URL; removed dead sources after end-to-end validation |
| 2.3.0 | 2026-06-29 | Four insurance categories added; categories reordered |
| 2.2.0 | 2026-06-29 | Breaking: sources moved from an internal JSON file to a publishable config, grouped by category |
| 2.1.0 | 2026-06-29 | The select card, the sources endpoint, HTML entity decoding |
| 2.0.0 | 2026-06-29 | The redesigned card: hero item, favicon, relative timestamps, skeleton loader |
The full history is in CHANGELOG.md.
Who maintains this, what it is built on, how to contribute to it, and the terms it is published under.
MIT, and it stays MIT. Nothing is paywalled, nothing is held back for sponsors, and there is no separate commercial licence to buy.
The MIT License (MIT)
Copyright (c) Gabriele Sbaiz <gabriele@sbaiz.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
The warranty disclaimer and limitation of liability in the full text apply in full. Read it in LICENSE.md.
The package renders content published by third parties, whose availability, accuracy and continued existence are outside its control. The bundled presets are a convenience, not an endorsement — feeds break, move and disappear without notice. Deciding what is acceptable to display inside an administration panel is the deploying application’s responsibility, not this package’s.
| Role | Who |
|---|---|
| Author and maintainer | Gabriele Sbaiz — gabriele@sbaiz.com |
| Contributors | Everyone who has sent a pull request |
| Dependency | Constraint | Why |
|---|---|---|
php | ^8.2 | Readonly promoted properties, enums, named arguments |
illuminate/support | ^11.0|^12.0 | The HTTP client, cache, queue, events and container |
spatie/laravel-package-tools | ^1.16 | Config, translations and command registration |
laravel/nova | ^5.0 | The card classes extend Nova’s Card, so it is a hard dependency |
ext-simplexml | bundled with PHP | RSS, Atom and RDF parsing |
Feed parsing uses PHP’s own SimpleXML rather than a parsing library, so the package adds no XML dependency to your application. The card scaffolding started from Nova’s own card generator.
Pull requests are welcome, and so are good issues. The fastest way to get a fix merged is to arrive with a failing test.
# fork, clone, then:
composer install
composer test # 89 tests, no network access
composer analyse # PHPStan level 5 over src, config, routes
composer format # Pint
npm install
npm run build # rebuilds dist/js/card.js and dist/css/card.css
The suite covers each feed dialect against fixtures, the cache pipeline (freshness, stale-while-revalidate, 304 revalidation, serving stale on failure), SSRF rejection of unsigned URLs, both throttles, the gate, every artisan command and an OPML round trip. No test reaches the network.
| Workflow | Runs |
|---|---|
tests.yml | Pest on PHP 8.2, 8.3 and 8.4 × Laravel 11, 12 and 13 (13 on PHP 8.3+) |
static-analysis.yml | PHPStan with Larastan |
lint.yml | Pint, the asset build, and the documentation playground suites |
NOVA_USERNAME and NOVA_LICENSE_KEY secrets before composer install can resolve laravel/nova. Without them the test workflow cannot run — that is a licence constraint, not a bug in the workflow.tests/Fixtures/ and the HTTP client is faked. A test that fetches a real feed will not be merged.php artisan nova-rss:check has to pass for them.UPGRADING.md.npm run build before opening the pull request; CI fails if dist/ has drifted from source.Bugs and feature requests go through GitHub issues, which ask for the package, PHP and Laravel versions — a dead feed usually turns out to be a specific publisher, so the feed URL helps more than anything else.
Security vulnerabilities are the exception: email gabriele@sbaiz.com rather than opening a public issue. See Security.
The package follows semantic versioning. Deprecated classes survive one major version: the v2 names — NovaCardRssNews, NovaCardRssNewsSelect and CardServiceProvider — still work in 3.x and are removed in 4.0.
| Version | PHP | Laravel | Nova | Status |
|---|---|---|---|---|
| 3.x | 8.2 – 8.4 | 11, 12, 13 | 5 | Active |
| 2.x | 8.0+ | 10, 11, 12 | 5 | Security fixes only |
| 1.x | 8.0+ | 9, 10 | 4 | End of life |
A new Laravel or Nova major is supported as soon as the suite passes against it. That work, rather than new features, is what sponsorship pays for.
This is maintained on evenings and weekends, alongside a full-time job writing insurance software. If the package saves you time, sponsoring from $5 a month keeps it green across new framework releases. A star and a good bug report help too, and cost nothing.