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 via date('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 by position for editor ordering, not by updated for freshness. A feed sorts by updated() 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