markocupic/rss-feed-generator-bundle
A Contao bundle for generating RSS feeds from your site content. Configure feed channels, map pages and archives, and publish valid RSS/Atom XML at customizable URLs for readers and aggregators, with straightforward setup through the Contao backend.
Install and enable. composer require markocupic/rss-feed-generator-bundle. With Symfony Flex it should register itself; otherwise add it manually to config/bundles.php. In a Contao 4/5 setup, also clear the container cache (bin/console cache:clear) so the bundle's services are compiled into the app.
Find the service first. Before writing a line of code, list what the bundle actually gives you:
bin/console debug:container | grep -i rss
ls vendor/markocupic/rss-feed-generator-bundle/src
The package is deliberately small, so the useful surface is one generator class plus maybe a DI extension. Read src/ and DependencyInjection/ — it's a five-minute read and it will save you guessing at method names.
First use case: a "latest posts" feed. Create a controller action that fetches your newest entities and hands them to the generator:
#[Route('/feed/news.xml', name: 'news_feed', defaults: ['_format' => 'xml'])]
public function newsFeed(PostRepository $posts, RssGenerator $rss, UrlGeneratorInterface $urls): Response
{
foreach ($posts->findLatest(50) as $post) {
$rss->addItem(
title: $post->getTitle(),
link: $urls->generate('post_show', ['slug' => $post->getSlug()], UrlGeneratorInterface::ABSOLUTE_URL),
description: $post->getTeaser(),
pubDate: $post->getPublishedAt(),
guid: 'post-'.$post->getId(),
);
}
return new Response($rss->render(), 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
}
Exact class/method names will vary — mirror whatever src/ exposes. If the bundle returns a Response itself, skip the wrapper and just return it.
Thin controller, fat query. Keep the controller to query → map → render. Put the "what belongs in the feed" logic in a repository method (findLatestFeedItems($limit)) so you can unit-test it and reuse it for sitemaps or JSON APIs. Always add an explicit ORDER BY publishedAt DESC and a LIMIT; unbounded feeds are the fastest way to blow up memory and response times.
Absolute URLs, always. RSS readers have no base URL. Generate every <link>, <guid>, and <enclosure> with UrlGeneratorInterface::ABSOLUTE_URL, and set framework.router.default_uri / trusted proxies so the host is correct behind a load balancer or during CLI-warmed cache.
Multiple feeds from one code path. Parameterize by a slug or query arg and validate against an allowlist rather than reflecting raw input:
$feeds = ['news' => Post::class, 'events' => Event::class];
$entity = $feeds[$request->query->get('channel')] ?? throw $this->createNotFoundException();
This gives you /feed/{channel}.xml with one controller and one test.
Route requirements to avoid content-negotiation surprises. Constrain the suffix so _format is deterministic:
#[Route('/feed/{channel}.xml', requirements: ['channel' => 'news|events'], defaults: ['_format' => 'xml'])]
Cache aggressively but correctly. Feeds are read-mostly. Wrap the response in Symfony's HTTP cache:
$response->setPublic();
$response->setMaxAge(900);
$response->setLastModified($latestPost->getUpdatedAt());
Also consider an ETag built from the item count + max updatedAt. Combine with #[Cache(...)] on the controller or an ESI-friendly reverse proxy. Refresh-on-publish is usually enough; don't bother with a message-driven cache purge unless feeds are huge.
Item payload checklist. For each entry populate: title, link, description (plain text teaser, not full HTML), pubDate (RFC 2822), guid that is stable across renames, plus optional author, category, and enclosure for media. Readers de-duplicate on guid, so make it a permalink or an immutable ID.
Reuse through a base class. If you have three or four feeds (news, blog, events, podcast), extract an abstract AbstractFeedController with getItems(): iterable and a shared render(). The bundle stays a serializer; your app owns the domain mapping.
Test it as a functional test, not a unit test. The valuable assertion is the HTTP contract:
$client->request('GET', '/feed/news.xml');
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('Content-Type', 'application/rss+xml; charset=UTF-8');
$this->assertStringContainsString('<rss', $client->getResponse()->getContent());
Then parse with simplexml_load_string() to confirm it's well-formed and count <item> nodes.
Contao-specific integration. If you're running this under Contao, decide whether you want a Contao front-end module / news-reader feed or a plain Symfony route. The bundle is the plain route; keep it that way unless you need page-level permissions, and register the route under a Contao page's alias only if you need it inside the site structure.
src/ shows today." Pin the version in composer.json ("^1.0" or exact) and be ready to swap it for laminas/laminas-feed or debril/rss-atom-bundle — the exit cost is one class. or unescaped & into a description. Either strip tags to plain text or let the generator/CDATA wrapper handle it — never both. Validate with xmllint --noout feed.xml or the W3C Feed Validator before shipping.\DateTimeInterface::format(DATE_RSS) and make sure entities return DateTimeImmutable with a real timezone. Naive DateTime objects and UTC-vs-local mismatches cause items to appear out of order in readers.text/xml or a missing charset will break some readers and clients. Set application/rss+xml; charset=UTF-8 explicitly; don't rely on _format: xml alone.content_version value you bump on save.<title>, <link>, and <description> on the channel even when there are zero items. If your generator is silent on those, add them. Test the empty-feed case — it's the one nobody writes a fixture for.debug:container RssGenerator after a cache clear; if there's no alias, define one in config/services.yaml.http://localhost in CLI-warmed or proxied environments. Configure trusted_proxies and framework.router.default_uri early — debugging this after launch is painful because readers cache the broken links.RssGenerator → your DecoratingRssGenerator) rather than forking the bundle. That keeps composer updates painless and makes the eventual library swap a one-file change.How can I help you explore Laravel packages today?