Problem
Readers want to subscribe to your blog, news track, or
release-notes section. A static feed file gets stale; you want
/feed.xml to reflect the current state of a sub-tree of the
page tree on every request.
Recipe
Same shape as the sitemap.xml recipe: a plugin
that catches the path in a RouteNotFound listener, iterates the
relevant pages, emits XML, exits. The only differences are the
schema (Atom 1.0 instead of sitemaps.org) and the iteration
(pages under one parent, ordered by updated(), capped to the
last ~20).
namespace Acme\Feed;
use League\Container\Container;
use Scriptor\Boot\Events\Frontend\RouteNotFound;
use Scriptor\Boot\Frontend\PageRepository;
use Scriptor\Boot\Plugin\Plugin as ScriptorPlugin;
use Scriptor\Boot\Plugin\PluginContext;
final class Plugin implements ScriptorPlugin
{
private const FEED_PATH = '/feed.xml';
private const PARENT_SLUG = 'blog';
private const MAX_ENTRIES = 20;
public function __construct(private readonly Container $container) {}
public function register(PluginContext $context): void
{
$context->subscribe(RouteNotFound::class, [$this, 'onUnresolved']);
}
public function version(): string { return '0.1.0'; }
public function onUnresolved(RouteNotFound $event): void
{
$path = '/' . $event->urlSegments->path(false);
if ($path !== self::FEED_PATH) return;
$repo = $this->container->get(PageRepository::class);
$parent = $repo->findBySlug(self::PARENT_SLUG);
if ($parent === null) {
header('HTTP/1.1 404 Not Found');
exit;
}
$entries = $repo->findActiveByParent((int) $parent->id());
usort($entries, static fn ($a, $b) => $b->updated() <=> $a->updated());
$entries = array_slice($entries, 0, self::MAX_ENTRIES);
$siteUrl = self::detectSiteUrl();
$feedUrl = $siteUrl . self::FEED_PATH;
$updated = $entries === [] ? time() : $entries[0]->updated();
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<feed xmlns="http://www.w3.org/2005/Atom">' . "\n";
$xml .= sprintf(" <title>%s</title>\n", self::xml($parent->name));
$xml .= sprintf(" <link href=\"%s\" rel=\"self\"/>\n", self::xml($feedUrl));
$xml .= sprintf(" <link href=\"%s/%s/\"/>\n", self::xml($siteUrl), self::xml($parent->slug));
$xml .= sprintf(" <id>%s</id>\n", self::xml($feedUrl));
$xml .= sprintf(" <updated>%s</updated>\n", date('c', $updated));
foreach ($entries as $entry) {
$entryUrl = sprintf('%s/%s/%s/', $siteUrl, $parent->slug, $entry->slug);
$xml .= " <entry>\n";
$xml .= sprintf(" <title>%s</title>\n", self::xml($entry->name));
$xml .= sprintf(" <link href=\"%s\"/>\n", self::xml($entryUrl));
$xml .= sprintf(" <id>%s</id>\n", self::xml($entryUrl));
$xml .= sprintf(" <updated>%s</updated>\n", date('c', $entry->updated()));
$xml .= sprintf(" <summary>%s</summary>\n", self::xml(self::excerpt($entry->content)));
$xml .= " </entry>\n";
}
$xml .= '</feed>' . "\n";
header('Content-Type: application/atom+xml; charset=utf-8');
echo $xml;
exit;
}
private static function xml(string $s): string
{
return htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8');
}
private static function excerpt(string $markdown, int $maxChars = 240): string
{
$text = trim(preg_replace('/\s+/', ' ', strip_tags($markdown)) ?? '');
return strlen($text) <= $maxChars ? $text : rtrim(substr($text, 0, $maxChars)) . '…';
}
private static function detectSiteUrl(): string
{
$scheme = (($_SERVER['HTTPS'] ?? '') === 'on') ? 'https' : 'http';
return $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
}
}
Three pieces worth flagging:
- Atom over RSS 2.0. The schema is tighter (mandatory
<id>,<updated>,<title>), date handling is unambiguous (ISO 8601 viadate('c', ...)), and modern readers prefer it. The variant block shows the RSS 2.0 shape for sites that need to support readers stuck on the old format. findActiveByParent()+ manual sort. Scriptor stores pages bypositionfor editor ordering, not byupdatedfor freshness. A feed sorts byupdated()descending; the in-memory sort + slice is fine for the few dozen entries a human-curated blog tends to carry.- The excerpt is the markdown source with tags stripped,
truncated to 240 characters. For a richer summary (first
paragraph as HTML, or
<content type="html">with the full rendered body) re-run$sanitizer->markdown($entry->content)through the ContentRendering decorator shape and put the result inside<content>instead of<summary>.
Variants
RSS 2.0 for legacy readers
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<rss version="2.0"><channel>' . "\n";
$xml .= sprintf(" <title>%s</title>\n", self::xml($parent->name));
$xml .= sprintf(" <link>%s/%s/</link>\n", self::xml($siteUrl), self::xml($parent->slug));
$xml .= " <description></description>\n";
foreach ($entries as $entry) {
$entryUrl = sprintf('%s/%s/%s/', $siteUrl, $parent->slug, $entry->slug);
$xml .= " <item>\n";
$xml .= sprintf(" <title>%s</title>\n", self::xml($entry->name));
$xml .= sprintf(" <link>%s</link>\n", self::xml($entryUrl));
$xml .= sprintf(" <guid isPermaLink=\"true\">%s</guid>\n", self::xml($entryUrl));
$xml .= sprintf(" <pubDate>%s</pubDate>\n", date(DATE_RSS, $entry->updated()));
$xml .= sprintf(" <description>%s</description>\n", self::xml(self::excerpt($entry->content)));
$xml .= " </item>\n";
}
$xml .= '</channel></rss>' . "\n";
header('Content-Type: application/rss+xml; charset=utf-8');
Note the RSS-specific date format (DATE_RSS constant, RFC 822
style) versus Atom's ISO 8601. Mixing them silently breaks
strict parsers.
Filter by template instead of parent
A "news" feed pulling pages from all over the tree where
template === 'news-item':
$entries = array_filter(
$repo->findAll(),
static fn ($p) => $p->active() && $p->template === 'news-item',
);
usort($entries, static fn ($a, $b) => $b->updated() <=> $a->updated());
$entries = array_slice($entries, 0, self::MAX_ENTRIES);
The entry-URL construction has to adapt; pathFor() from the
sitemap recipe is the right helper to lift here.
See also
- Generate sitemap.xml from PageRepository: the sibling recipe with the same RouteNotFound short-circuit shape and a similar PageRepository iteration
- Decorate page HTML in ContentRendering:
the right place to reach if your feed wants the full rendered
body inside
<content type="html"> PageRepository:findBySlug()+findActiveByParent()are the two methods this recipe leans onPage:name,slug,content,active(),updated()are the five fields touchedRouteNotFound: the event surface, plus the cooperation contract that lets multiple listeners stack- Atom 1.0 spec (RFC 4287) and RSS 2.0 spec upstream