From d6ffa1acfdd578d83acaa7f6bd3a2a6fdc703493 Mon Sep 17 00:00:00 2001
From: Daniel Scherzer
Date: Sun, 10 May 2026 12:27:54 -0700
Subject: [PATCH 1/2] [Blog] Add an RSS feed
---
composer.json | 1 +
src/Blog/feed-rss.xml | 130 +++++++++++++++++++++++++++
src/Pages/BlogFeedRSSPage.php | 22 +++++
src/Router.php | 2 +
tests/Blog/BlogFeedGeneratorTest.php | 94 +++++++++++++++++++
5 files changed, 249 insertions(+)
create mode 100644 src/Blog/feed-rss.xml
create mode 100644 src/Pages/BlogFeedRSSPage.php
create mode 100644 tests/Blog/BlogFeedGeneratorTest.php
diff --git a/composer.json b/composer.json
index 838f3e4..a3f808a 100644
--- a/composer.json
+++ b/composer.json
@@ -9,6 +9,7 @@
},
"require-dev": {
"danielescherzer/common-phpcs": "0.0.2",
+ "laminas/laminas-feed": "2.26.1",
"phpunit/phpunit": "~12.0",
"php-parallel-lint/php-parallel-lint": "^1.4",
"samdark/sitemap": "2.4.1"
diff --git a/src/Blog/feed-rss.xml b/src/Blog/feed-rss.xml
new file mode 100644
index 0000000..123c2d9
--- /dev/null
+++ b/src/Blog/feed-rss.xml
@@ -0,0 +1,130 @@
+
+
+
+ Daniel Scherzer's Blog
+ Entries from Daniel Scherzer' personal blog
+ Sun, 10 May 2026 00:00:00 +0000
+ https://github.com/DanielEScherzer/website-content
+ https://scherzer.dev/Blog
+
+ Notes for PHP Release Managers, Part 2
+ Sun, 10 May 2026 00:00:00 +0000
+ https://scherzer.dev/Blog/20260510-release-manager-notes-2
+
+
+ The Story of PHP 8.5.6 Release Candidate 3
+ Thu, 30 Apr 2026 00:00:00 +0000
+ https://scherzer.dev/Blog/20260430-php856-rc-3
+
+
+ PHP 8.6 Release Manager
+ Thu, 16 Apr 2026 00:00:00 +0000
+ https://scherzer.dev/Blog/20260416-php86-release-manager
+
+
+ Introducing define_deprecated() for PHP
+ Fri, 10 Apr 2026 00:00:00 +0000
+ https://scherzer.dev/Blog/20260410-define-deprecated
+
+
+ Friends in PHP
+ Mon, 09 Mar 2026 00:00:00 +0000
+ https://scherzer.dev/Blog/20260309-php-friends
+
+
+ ConFoo 2026
+ Mon, 02 Mar 2026 00:00:00 +0000
+ https://scherzer.dev/Blog/20260302-confoo
+
+
+ Notes for PHP Release Managers
+ Wed, 18 Feb 2026 00:00:00 +0000
+ https://scherzer.dev/Blog/20260218-release-manager-notes
+
+
+ This Year in PHP (2025)
+ Tue, 20 Jan 2026 00:00:00 +0000
+ https://scherzer.dev/Blog/20260120-php-2025
+
+
+ Project Euler in Rust, Part 2
+ Mon, 01 Dec 2025 00:00:00 +0000
+ https://scherzer.dev/Blog/20251201-rust-euler-2
+
+
+ Unplanned Downtime, November 2025
+ Tue, 25 Nov 2025 00:00:00 +0000
+ https://scherzer.dev/Blog/20251125-unplanned-downtime
+
+
+ MergePHP, November 2025
+ Tue, 18 Nov 2025 00:00:00 +0000
+ https://scherzer.dev/Blog/20251118-mergephp-talk
+
+
+ The Story of PHP 8.5.0 Release Candidate 5
+ Thu, 13 Nov 2025 00:00:00 +0000
+ https://scherzer.dev/Blog/20251113-release-candidate-5
+
+
+ Longhorn PHP 2025
+ Wed, 29 Oct 2025 00:00:00 +0000
+ https://scherzer.dev/Blog/20251029-longhorn-php
+
+
+ Project Euler in Rust
+ Fri, 19 Sep 2025 00:00:00 +0000
+ https://scherzer.dev/Blog/20250919-rust-euler
+
+
+ "Hello, World!" in Rust
+ Fri, 12 Sep 2025 00:00:00 +0000
+ https://scherzer.dev/Blog/20250912-hello-world-rust
+
+
+ First Rust Patch
+ Sat, 06 Sep 2025 00:00:00 +0000
+ https://scherzer.dev/Blog/20250906-mago-rust
+
+
+ Out-of-Band Signaling
+ Fri, 29 Aug 2025 00:00:00 +0000
+ https://scherzer.dev/Blog/20250829-out-of-band
+
+
+ #[\DelayedTargetValidation] Attribute Explained
+ Wed, 20 Aug 2025 00:00:00 +0000
+ https://scherzer.dev/Blog/20250820-delayed-target-validation
+
+
+ No PHP 8.5.0alpha3
+ Fri, 01 Aug 2025 00:00:00 +0000
+ https://scherzer.dev/Blog/20250801-no-alpha-3
+
+
+ Pygments Syntax Highlighting for Markdown
+ Fri, 18 Jul 2025 00:00:00 +0000
+ https://scherzer.dev/Blog/20250718-pygments-highlighting
+
+
+ A Tale of Two RFCs
+ Sun, 22 Jun 2025 00:00:00 +0000
+ https://scherzer.dev/Blog/20250622-two-rfcs
+
+
+ Attributes on Constants
+ Tue, 29 Apr 2025 00:00:00 +0000
+ https://scherzer.dev/Blog/20250429-attributes-on-constants
+
+
+ PHP 8.5 Release Manager
+ Thu, 17 Apr 2025 00:00:00 +0000
+ https://scherzer.dev/Blog/20250417-php85-release-manager
+
+
+ Website Launch
+ Wed, 09 Apr 2025 00:00:00 +0000
+ https://scherzer.dev/Blog/20250409-website-launch
+
+
+
diff --git a/src/Pages/BlogFeedRSSPage.php b/src/Pages/BlogFeedRSSPage.php
new file mode 100644
index 0000000..c2fefcc
--- /dev/null
+++ b/src/Pages/BlogFeedRSSPage.php
@@ -0,0 +1,22 @@
+getContent(),
+ [ 'Content-Type: application/xml' ],
+ 200
+ );
+ }
+
+ private function getContent(): string {
+ return file_get_contents( dirname( __DIR__ ) . '/Blog/feed-rss.xml' );
+ }
+
+}
diff --git a/src/Router.php b/src/Router.php
index 8ee7570..6629c6b 100644
--- a/src/Router.php
+++ b/src/Router.php
@@ -4,6 +4,7 @@
namespace DanielWebsite;
use DanielWebsite\Pages\AbstractPage;
+use DanielWebsite\Pages\BlogFeedRSSPage;
use DanielWebsite\Pages\BlogIndexPage;
use DanielWebsite\Pages\BlogPostPage;
use DanielWebsite\Pages\Error404Page;
@@ -68,6 +69,7 @@ public static function addRoutesCb( RouteCollector $r ): void {
$r->addRoute( 'GET', 'Thesis', ThesisPage::class );
$r->addRoute( 'GET', 'Work', WorkPage::class );
$r->addRoute( 'GET', 'Blog', BlogIndexPage::class );
+ $r->addRoute( 'GET', 'Blog/feed/rss.xml', BlogFeedRSSPage::class );
$r->addRoute( 'GET', 'Blog/{slug}', BlogPostPage::class );
$r->addRoute( 'GET', 'Tools', ToolPage::class );
$r->addRoute( 'GET', 'Tools/{tool}', ToolPage::class );
diff --git a/tests/Blog/BlogFeedGeneratorTest.php b/tests/Blog/BlogFeedGeneratorTest.php
new file mode 100644
index 0000000..369f715
--- /dev/null
+++ b/tests/Blog/BlogFeedGeneratorTest.php
@@ -0,0 +1,94 @@
+setTitle( 'Daniel Scherzer\'s Blog' );
+ $feed->setDescription( 'Entries from Daniel Scherzer\' personal blog' );
+ $feed->setLink( self::URL_BASE . '/Blog' );
+ $feed->setGenerator( 'https://github.com/DanielEScherzer/website-content' );
+
+ $store = new BlogPostStore();
+ $posts = $store->listBlogPosts();
+
+ $feed->setDateModified( $posts[0]->date );
+
+ // Only parse the YAML frontmatter
+ $frontMatterExt = new FrontMatterExtension();
+ $parser = $frontMatterExt->getFrontMatterParser();
+
+ foreach ( $posts as $post ) {
+ $cfg = $parser->parse( $post->markdown )->getFrontMatter();
+ $post->setConfig( $cfg ?? [] );
+
+ $entry = $feed->createEntry();
+ $entry->setTitle( $post->getTitle() );
+ $entry->setLink( self::URL_BASE . '/Blog/' . $post->slug );
+ $entry->setDateCreated( $post->date );
+
+ $feed->addEntry( $entry );
+ }
+
+ return $feed;
+ }
+
+ public function testRSSFeed() {
+ // There doesn't seem to be an easy way to disable the Slash extension
+ Writer::reset();
+ $old = Writer::getExtensionManager();
+ $manager = new class( $old ) extends ExtensionManager {
+ public function has( $entryName ) {
+ if ( $entryName === 'Slash\Renderer\Entry' ) {
+ // Needs to return true for the check of if the extension
+ // exists, otherwise Laminas throws an exception
+ $bt = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 2 );
+ if (
+ $bt[1]['class'] === Writer::class
+ && $bt[1]['function'] === 'hasExtension'
+ ) {
+ return true;
+ }
+ return false;
+ }
+ return parent::has( $entryName );
+ }
+ };
+ Writer::setExtensionManager( $manager );
+
+ $expected = $this->generateFeed()->export( 'rss' );
+ // Easier than trying to reimplement the whole chain to remove the guid
+ $expected = preg_replace( "/\n\s+\S+?<\/guid>/", '', $expected );
+
+ $actualFeedPath = dirname( __DIR__, 2 ) . '/src/Blog/feed-rss.xml';
+ if ( getenv( 'TESTS_UPDATE_EXPECTED' ) === '1' ) {
+ file_put_contents( $actualFeedPath, $expected );
+ }
+ $this->assertStringEqualsFile( $actualFeedPath, $expected );
+ }
+
+}
From 21d4da7741b69ea47824615fb821e095c538a543 Mon Sep 17 00:00:00 2001
From: Daniel Scherzer
Date: Sun, 17 May 2026 11:02:18 -0700
Subject: [PATCH 2/2] [Blog] Add post about RSS feed
---
sitemap.xml | 3 +
src/Blog/feed-rss.xml | 7 +-
src/Blog/posts/20260517-blog-rss.md | 95 ++++++++++++++++++++++++++++
src/Pages/BlogFeedPage.php | 86 +++++++++++++++++++++++++
src/Pages/BlogFeedRSSPage.php | 22 -------
src/Router.php | 5 +-
tests/Blog/BlogFeedGeneratorTest.php | 3 +-
tests/data/Home.html | 7 +-
tests/data/blog-index.html | 4 +-
9 files changed, 200 insertions(+), 32 deletions(-)
create mode 100644 src/Blog/posts/20260517-blog-rss.md
create mode 100644 src/Pages/BlogFeedPage.php
delete mode 100644 src/Pages/BlogFeedRSSPage.php
diff --git a/sitemap.xml b/sitemap.xml
index a84fd49..515c2d8 100644
--- a/sitemap.xml
+++ b/sitemap.xml
@@ -93,4 +93,7 @@
https://scherzer.dev/Blog/20260510-release-manager-notes-2
+
+ https://scherzer.dev/Blog/20260517-blog-rss
+
diff --git a/src/Blog/feed-rss.xml b/src/Blog/feed-rss.xml
index 123c2d9..e291c2f 100644
--- a/src/Blog/feed-rss.xml
+++ b/src/Blog/feed-rss.xml
@@ -3,9 +3,14 @@
Daniel Scherzer's BlogEntries from Daniel Scherzer' personal blog
- Sun, 10 May 2026 00:00:00 +0000
+ Sun, 17 May 2026 00:00:00 +0000https://github.com/DanielEScherzer/website-content
https://scherzer.dev/Blog
+
+ Blog RSS Feed
+ Sun, 17 May 2026 00:00:00 +0000
+ https://scherzer.dev/Blog/20260517-blog-rss
+ Notes for PHP Release Managers, Part 2Sun, 10 May 2026 00:00:00 +0000
diff --git a/src/Blog/posts/20260517-blog-rss.md b/src/Blog/posts/20260517-blog-rss.md
new file mode 100644
index 0000000..c30e6ac
--- /dev/null
+++ b/src/Blog/posts/20260517-blog-rss.md
@@ -0,0 +1,95 @@
+---
+title: Blog RSS Feed
+extensions:
+ pygments: true
+---
+
+# Blog RSS Feed
+
+Last month, I received [a request][rss-req] to add an RSS feed to my blog. I am
+happy to share that I have added such a feed, available at
+[/Blog/feed/rss.xml][rss-link].
+
+RSS (short for "Really Simple Syndication") is a web feed specification built on
+top of XML. Rather than generating the XML entirely from scratch (potentially
+using the PHP dom extension), I opted to use an open source library,
+[laminas/laminas-feed][pgk-laminas], to create the XML content. While the
+library certainly helped, I ran into a few challenges along the way.
+
+## Laminas package
+
+The first was the fact that the library unconditionally enables a "Slash"
+extension which adds some extra elements to the output. Rather than manually
+removing those elements from the resulting XML, I wanted to work within the
+constraints of the library API. The result? A custom extension manager that
+behaves differently based on the backtrace. The Laminas package still believes
+that the extension exists, but the slash extension is skipped during
+registration.
+
+```php startinline=True
+$old = Writer::getExtensionManager();
+$manager = new class( $old ) extends ExtensionManager {
+ public function has( $entryName ) {
+ if ( $entryName === 'Slash\Renderer\Entry' ) {
+ // Needs to return true when checking if the extension
+ // exists, otherwise Laminas throws an exception
+ $bt = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 2 );
+ if (
+ $bt[1]['class'] === Writer::class
+ && $bt[1]['function'] === 'hasExtension'
+ ) {
+ return true;
+ }
+ return false;
+ }
+ return parent::has( $entryName );
+ }
+};
+Writer::setExtensionManager( $manager );
+```
+
+While I'm not the most pleased with this approach, it does work, and it presents
+a great example for my talk at PHPTek next week about what is and is not part of
+the public API of PHP libraries.
+
+Another issue that I encountered was the unconditional addition of `` tags
+in the output - for these, I just removed them from the XML result with a
+post-processing step.
+
+Of course, I could have used another library, or forked the Laminas package,
+but the Laminas package seemed like the easiest to use.
+
+## Forward compatibility
+
+I had initially planned for the RSS feed to live at `.../Blog/rss.xml`, but
+realized that I might want to support other feed formats (e.g. Atom) in the
+future. To keep all feeds organized together, I put the RSS feed at
+`.../Blog/feed/rss.xml`, allowing other feeds to be added more easily. If you
+visit the feed page without specifying a feed format ([/Blog/feed][feeds-link]),
+a list of available feeds is shown; it is also shown when an unknown feed is
+requested.
+
+## Caching
+
+Rather than regenerating the RSS feed on every request, I generate it ahead of
+time and store the resulting XML in my website's git repository. At runtime, the
+cached file is simply read from disk. Tests exist to ensure that the cached
+version remains up-to-date.
+
+Thinking about this further, the same approach could be taken to every other
+known page on my website. Other than HTTP 404 and 405 pages that depend on
+what path or method were used, the rest of my pages are static and could be
+similarly cached. If performance ever becomes a concern, I may extend the same
+approach to the rest of the site.
+
+## Field testing
+
+This blog post was published at the same time the RSS feed was created, so it
+does not serve as a test of the feed working properly. After PHPTek next week,
+I plan to write up another post about my experiences there, which will serve as
+a great opportunity to confirm that the RSS feed behaves as expected.
+
+[feeds-link]: /Blog/feed
+[pgk-laminas]: https://packagist.org/packages/laminas/laminas-feed
+[rss-link]: /Blog/feed/rss.xml
+[rss-req]: https://github.com/DanielEScherzer/website-content/issues/79
diff --git a/src/Pages/BlogFeedPage.php b/src/Pages/BlogFeedPage.php
new file mode 100644
index 0000000..ca048ad
--- /dev/null
+++ b/src/Pages/BlogFeedPage.php
@@ -0,0 +1,86 @@
+feedName = $params['feed'] ?? null;
+ $this->head->append(
+ FluentHTML::fromTag( 'title' )->addChild( 'Blog feeds' )
+ );
+ }
+
+ public function getResponse(): WebResponse {
+ if ( $this->feedName === 'rss.xml' ) {
+ // Bypass BasePage handling and return raw xml
+ $content = file_get_contents( dirname( __DIR__ ) . '/Blog/feed-rss.xml' );
+ return new WebResponse(
+ $content,
+ [ 'Content-Type: application/xml' ],
+ 200
+ );
+ }
+ return parent::getResponse();
+ }
+
+ protected function build(): void {
+ if ( $this->feedName === 'rss.xml' ) {
+ return;
+ }
+ if ( $this->feedName !== null ) {
+ $this->addStyleSheet( 'error-styles.css' );
+ $feed = $this->feedName;
+ $this->contentWrapper->append(
+ FluentHTML::make(
+ 'div',
+ [ 'class' => 'error-box' ],
+ [
+ FluentHTML::make( 'h1', [], 'Error' ),
+ FluentHTML::make(
+ 'p',
+ [],
+ "The requested blog feed `$feed` is not recognized"
+ ),
+ ]
+ )
+ );
+ $this->setResponseCode( 404 );
+ }
+ $this->contentWrapper->append(
+ FluentHTML::make( 'h1', [], 'Known feed types' ),
+ FluentHTML::make(
+ 'ul',
+ [],
+ [
+ FluentHTML::make(
+ 'li',
+ [],
+ FluentHTML::make(
+ 'a',
+ [ 'href' => '/Blog' ],
+ 'HTML index of blog posts'
+ )
+ ),
+ FluentHTML::make(
+ 'li',
+ [],
+ FluentHTML::make(
+ 'a',
+ [ 'href' => '/Blog/feed/rss.xml' ],
+ 'RSS feed of blog posts'
+ )
+ ),
+ ]
+ )
+ );
+ }
+
+}
diff --git a/src/Pages/BlogFeedRSSPage.php b/src/Pages/BlogFeedRSSPage.php
deleted file mode 100644
index c2fefcc..0000000
--- a/src/Pages/BlogFeedRSSPage.php
+++ /dev/null
@@ -1,22 +0,0 @@
-getContent(),
- [ 'Content-Type: application/xml' ],
- 200
- );
- }
-
- private function getContent(): string {
- return file_get_contents( dirname( __DIR__ ) . '/Blog/feed-rss.xml' );
- }
-
-}
diff --git a/src/Router.php b/src/Router.php
index 6629c6b..57703e6 100644
--- a/src/Router.php
+++ b/src/Router.php
@@ -4,7 +4,7 @@
namespace DanielWebsite;
use DanielWebsite\Pages\AbstractPage;
-use DanielWebsite\Pages\BlogFeedRSSPage;
+use DanielWebsite\Pages\BlogFeedPage;
use DanielWebsite\Pages\BlogIndexPage;
use DanielWebsite\Pages\BlogPostPage;
use DanielWebsite\Pages\Error404Page;
@@ -69,7 +69,8 @@ public static function addRoutesCb( RouteCollector $r ): void {
$r->addRoute( 'GET', 'Thesis', ThesisPage::class );
$r->addRoute( 'GET', 'Work', WorkPage::class );
$r->addRoute( 'GET', 'Blog', BlogIndexPage::class );
- $r->addRoute( 'GET', 'Blog/feed/rss.xml', BlogFeedRSSPage::class );
+ $r->addRoute( 'GET', 'Blog/feed', BlogFeedPage::class );
+ $r->addRoute( 'GET', 'Blog/feed/{feed}', BlogFeedPage::class );
$r->addRoute( 'GET', 'Blog/{slug}', BlogPostPage::class );
$r->addRoute( 'GET', 'Tools', ToolPage::class );
$r->addRoute( 'GET', 'Tools/{tool}', ToolPage::class );
diff --git a/tests/Blog/BlogFeedGeneratorTest.php b/tests/Blog/BlogFeedGeneratorTest.php
index 369f715..afd08f9 100644
--- a/tests/Blog/BlogFeedGeneratorTest.php
+++ b/tests/Blog/BlogFeedGeneratorTest.php
@@ -59,12 +59,11 @@ private function generateFeed(): Feed {
public function testRSSFeed() {
// There doesn't seem to be an easy way to disable the Slash extension
- Writer::reset();
$old = Writer::getExtensionManager();
$manager = new class( $old ) extends ExtensionManager {
public function has( $entryName ) {
if ( $entryName === 'Slash\Renderer\Entry' ) {
- // Needs to return true for the check of if the extension
+ // Needs to return true when checking if the extension
// exists, otherwise Laminas throws an exception
$bt = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 2 );
if (
diff --git a/tests/data/Home.html b/tests/data/Home.html
index 66af69b..cc4974c 100644
--- a/tests/data/Home.html
+++ b/tests/data/Home.html
@@ -7,7 +7,6 @@
a release manager for PHP 8.5 (where I am one of two "rookie" release managers)
and for PHP 8.6 (where I am the "veteran" release manager). I also contribute to
PHP as a developer, and as the maintainer of the built-in Reflection extension.
See the links in the navigation bar above for more information about my
-experience.
I also have a blog. You can see a full index of my posts here. My latest blog post is:
Notes for PHP Release Managers, Part 2
Sunday, 10 May 2026
A few months ago, I wrote up a blog post about my experience
-being a PHP release manager and the kind of commitment that the role requires.
-Now, I want to document some of the decision-making and process related to the
-latest release that I oversaw, PHP 8.5.6. Continue reading...