Skip to main content
Being Idea Innovations
Generate a Valid RSS Feed with PHP (Escaping, GUIDs and Caching)
Back to Blog

Generate a Valid RSS Feed with PHP (Escaping, GUIDs and Caching)

26 February 20185 min readPHP
Share:

An RSS feed lets readers follow your site in a feed reader, and lets other services syndicate your content. It is XML, which means it must be well-formed — and that single requirement is where nearly every hand-rolled feed falls over.

The rule that breaks most feeds

XML has no tolerance for malformed markup. One unescaped & anywhere in the document makes the entire feed invalid, and readers reject the whole thing — not just that item.

The version of this article that used to live here handled it like this:

// Strips characters instead of escaping them
$name = strtr($row['name'], array('.' => '', ',' => '', '@' => '', '&' => ''));
$rssfeed .= '<title>' . $name . '</title>';

That mangles your titles — "Profit & Loss, Explained" becomes "Profit Loss Explained" — and still does not solve the problem, because < and > pass straight through and break the document anyway.

Escape properly instead:

function xmlEscape(string $value): string
{
    return htmlspecialchars($value, ENT_XML1 | ENT_QUOTES, 'UTF-8');
}

ENT_XML1 matters. The default ENT_HTML401 produces entities like &nbsp; that are undefined in XML and will themselves invalidate the feed.

For content containing HTML — a post excerpt, say — wrap it in CDATA rather than escaping every tag:

function cdata(string $html): string
{
    // "]]>" would terminate the section early; split it across two sections.
    return '<![CDATA[' . str_replace(']]>', ']]]]><![CDATA[>', $html) . ']]>';
}

The encoding contradiction

The old code did this:

header("Content-Type: application/rss+xml; charset=ISO-8859-1");
$rssfeed = '<?xml version="1.0" encoding="UTF-8"?>';

The HTTP header says Latin-1, the XML declaration says UTF-8. They contradict each other, and the header usually wins — so every non-ASCII character arrives mangled. Use UTF-8 in both, and make sure your database connection is utf8mb4 too, or the corruption starts before PHP ever sees the data.

A feed that validates

<?php
declare(strict_types=1);

require __DIR__ . '/bootstrap.php';

const SITE_URL  = 'https://www.beingidea.com';
const FEED_URL  = SITE_URL . '/feed.xml';
const MAX_ITEMS = 20;

$pdo = new PDO('mysql:host=localhost;dbname=blog;charset=utf8mb4', $user, $pass, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$posts = $pdo->query(
    'SELECT id, slug, title, excerpt, published_at, updated_at
       FROM posts
      WHERE status = "published"
      ORDER BY published_at DESC
      LIMIT ' . MAX_ITEMS
)->fetchAll();

$lastBuild = $posts[0]['updated_at'] ?? 'now';

header('Content-Type: application/rss+xml; charset=UTF-8');

echo '<?xml version="1.0" encoding="UTF-8"?>', "\n";
?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Being Idea | Engineering Blog</title>
    <link><?= xmlEscape(SITE_URL) ?></link>
    <description>Articles on software engineering from the Being Idea team.</description>
    <language>en-gb</language>
    <lastBuildDate><?= gmdate(DATE_RSS, strtotime($lastBuild)) ?></lastBuildDate>

    <!-- Required by feed validators: the feed's own canonical URL. -->
    <atom:link href="<?= xmlEscape(FEED_URL) ?>" rel="self" type="application/rss+xml" />

<?php foreach ($posts as $post):
    $url = SITE_URL . '/blog/' . rawurlencode($post['slug']);
?>
    <item>
      <title><?= xmlEscape($post['title']) ?></title>
      <link><?= xmlEscape($url) ?></link>
      <description><?= cdata($post['excerpt']) ?></description>
      <pubDate><?= gmdate(DATE_RSS, strtotime($post['published_at'])) ?></pubDate>

      <!-- A permanent, never-changing identifier. isPermaLink="false"
           tells readers not to treat it as a URL. -->
      <guid isPermaLink="false">beingidea-post-<?= (int) $post['id'] ?></guid>
    </item>
<?php endforeach; ?>
  </channel>
</rss>

The fields the old version omitted

  • <description> and <pubDate> on each item. The original emitted only a title and link, so readers had nothing to display and no way to order items.
  • <guid>. Without it, readers fall back to the link — so changing a URL makes every subscriber see the post as brand new. Use a stable identifier that never changes, and mark it isPermaLink="false".
  • <atom:link rel="self">. The W3C validator warns without it, and it tells aggregators where the feed canonically lives.
  • <lastBuildDate>. Lets readers skip a feed that has not changed.

Dates must be RFC 822

RSS requires RFC 822 dates — Mon, 24 Feb 2026 09:27:16 +0000. PHP's DATE_RSS constant produces exactly that. Use gmdate() rather than date() so the offset is genuinely UTC; mixing local time with a +0000 suffix silently shifts every timestamp.

Full content vs summaries

To syndicate whole articles, use the content namespace:

<description><?= cdata($post['excerpt']) ?></description>
<content:encoded><?= cdata($post['body_html']) ?></content:encoded>

Make image and link URLs absolute in that HTML — relative paths resolve against the reader's own domain and break. And be deliberate about the trade-off: full content is friendlier to readers, but makes scraping your work trivial.

Caching and conditional GET

Feed readers poll frequently, often hourly, mostly to find nothing changed. Support conditional requests and most of that traffic becomes a 304 with no body:

$etag = '"' . md5($lastBuild . MAX_ITEMS) . '"';
$lastModified = gmdate('D, d M Y H:i:s', strtotime($lastBuild)) . ' GMT';

header('ETag: ' . $etag);
header('Last-Modified: ' . $lastModified);
header('Cache-Control: public, max-age=1800');

$ifNoneMatch = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
$ifModified  = $_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '';

if ($ifNoneMatch === $etag || $ifModified === $lastModified) {
    http_response_code(304);
    exit;   // no body on a 304
}

On a feed with a few thousand subscribers this is a substantial saving for a dozen lines.

Make it discoverable

<link rel="alternate" type="application/rss+xml"
      title="Being Idea | Engineering Blog"
      href="https://www.beingidea.com/feed.xml" />

This goes in your site's <head>. It is how browsers and readers auto-discover the feed when someone pastes your homepage URL.

On serving it at a tidy path: the old article suggested creating a /feed/ directory containing index.php. That works, but a rewrite rule is cleaner and does not scatter code into odd folders:

# Apache
RewriteRule ^feed\.xml$ /rss.php [L]
# nginx
location = /feed.xml { try_files /dev/null /rss.php; }

Validate before you publish

Run the output through the W3C Feed Validation Service. Common failures:

ErrorCause
"XML parsing error"Unescaped &, < or >
"Invalid pubDate"Not RFC 822 format
"Missing atom:link"No self-referencing link
"Content type should be application/rss+xml"Wrong or missing header
Blank page in readersWhitespace or a BOM before the XML declaration

That last one is worth watching for. Any output before <?xml — a stray newline after a closing ?> in an included file, or a UTF-8 BOM saved by your editor — makes the document invalid. Omit the closing ?> in pure-PHP files and save without a BOM.

RSS, Atom or JSON Feed?

RSS 2.0 has the widest support and is the safe default. Atom is more strictly specified and handles dates and content types more rigorously. JSON Feed is far easier to generate and consume if you are already producing JSON, but reader support is thinner.

Publishing RSS alone is fine. If you want to offer more, generate Atom from the same data rather than maintaining two hand-written templates.

Summary

  • Escape with ENT_XML1 — never strip characters instead.
  • UTF-8 in the header and the XML declaration.
  • Give every item a title, link, description, pubDate and stable guid.
  • Include atom:link rel="self".
  • RFC 822 dates via gmdate(DATE_RSS, …).
  • Support ETag and Last-Modified for conditional GET.
  • Add the discovery <link> to your site head.
  • No output before the XML declaration.
Share:

Written by

Amit Verma

Founder / Senior Software Engineer

Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.

More articles by Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers