-
Notifications
You must be signed in to change notification settings - Fork 94
Blog Feed
Rumen Damyanov edited this page Jul 29, 2025
·
1 revision
This is a complete example of implementing a blog feed system with multiple feed types, categories, authors, and advanced features.
First, let's set up the database structure for a typical blog:
-- Posts table
CREATE TABLE posts (
id INT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
content TEXT NOT NULL,
excerpt TEXT,
author_id INT NOT NULL,
featured_image VARCHAR(255),
published BOOLEAN DEFAULT FALSE,
published_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_published (published, published_at),
INDEX idx_author (author_id),
INDEX idx_slug (slug)
);
-- Categories table
CREATE TABLE categories (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Post-Category relationship
CREATE TABLE post_categories (
post_id INT NOT NULL,
category_id INT NOT NULL,
PRIMARY KEY (post_id, category_id),
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);
-- Authors table
CREATE TABLE authors (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
bio TEXT,
avatar VARCHAR(255),
website VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Comments table
CREATE TABLE comments (
id INT PRIMARY KEY AUTO_INCREMENT,
post_id INT NOT NULL,
author_name VARCHAR(100) NOT NULL,
author_email VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
approved BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
INDEX idx_post_approved (post_id, approved),
INDEX idx_created (created_at)
);
<?php
require 'vendor/autoload.php';
use Rumenx\Feed\FeedFactory;
class BlogFeedManager
{
private PDO $pdo;
private string $baseUrl;
private string $siteName;
private array $cache = [];
public function __construct(PDO $pdo, string $baseUrl, string $siteName)
{
$this->pdo = $pdo;
$this->baseUrl = rtrim($baseUrl, '/');
$this->siteName = $siteName;
}
public function generateMainFeed(int $limit = 20): string
{
$feed = FeedFactory::create();
$feed->setTitle($this->siteName);
$feed->setDescription('Latest blog posts from ' . $this->siteName);
$feed->setLink($this->baseUrl);
$posts = $this->getLatestPosts($limit);
foreach ($posts as $post) {
$feed->addItem([
'title' => $post['title'],
'author' => $post['author_name'],
'link' => $this->baseUrl . '/posts/' . $post['slug'],
'pubdate' => $post['published_at'],
'description' => $post['excerpt'] ?: $this->generateExcerpt($post['content']),
'content' => $post['content'],
'category' => explode(',', $post['categories'] ?? ''),
'guid' => $this->baseUrl . '/posts/' . $post['slug'],
'image' => $post['featured_image'] ? $this->baseUrl . '/' . $post['featured_image'] : null
]);
}
return $feed->render('rss');
}
public function generateCategoryFeed(string $categorySlug, int $limit = 20): string
{
$category = $this->getCategoryBySlug($categorySlug);
if (!$category) {
throw new InvalidArgumentException("Category '{$categorySlug}' not found");
}
$feed = FeedFactory::create();
$feed->setTitle($this->siteName . ' - ' . $category['name']);
$feed->setDescription('Latest posts from ' . $category['name'] . ' category');
$feed->setLink($this->baseUrl . '/category/' . $categorySlug);
$posts = $this->getPostsByCategory($category['id'], $limit);
foreach ($posts as $post) {
$feed->addItem([
'title' => $post['title'],
'author' => $post['author_name'],
'link' => $this->baseUrl . '/posts/' . $post['slug'],
'pubdate' => $post['published_at'],
'description' => $post['excerpt'] ?: $this->generateExcerpt($post['content']),
'content' => $post['content'],
'category' => [$category['name']],
'guid' => $this->baseUrl . '/posts/' . $post['slug']
]);
}
return $feed->render('rss');
}
public function generateAuthorFeed(int $authorId, int $limit = 20): string
{
$author = $this->getAuthorById($authorId);
if (!$author) {
throw new InvalidArgumentException("Author with ID '{$authorId}' not found");
}
$feed = FeedFactory::create();
$feed->setTitle($this->siteName . ' - Posts by ' . $author['name']);
$feed->setDescription('Latest posts by ' . $author['name']);
$feed->setLink($this->baseUrl . '/author/' . $author['id']);
$posts = $this->getPostsByAuthor($authorId, $limit);
foreach ($posts as $post) {
$feed->addItem([
'title' => $post['title'],
'author' => $author['name'],
'link' => $this->baseUrl . '/posts/' . $post['slug'],
'pubdate' => $post['published_at'],
'description' => $post['excerpt'] ?: $this->generateExcerpt($post['content']),
'content' => $post['content'],
'category' => explode(',', $post['categories'] ?? ''),
'guid' => $this->baseUrl . '/posts/' . $post['slug']
]);
}
return $feed->render('rss');
}
public function generateCommentsFeed(int $postId = null, int $limit = 50): string
{
$feed = FeedFactory::create();
if ($postId) {
$post = $this->getPostById($postId);
$feed->setTitle('Comments for: ' . $post['title']);
$feed->setDescription('Latest comments on ' . $post['title']);
$feed->setLink($this->baseUrl . '/posts/' . $post['slug'] . '#comments');
} else {
$feed->setTitle($this->siteName . ' - Latest Comments');
$feed->setDescription('Latest comments from ' . $this->siteName);
$feed->setLink($this->baseUrl . '/comments');
}
$comments = $this->getLatestComments($postId, $limit);
foreach ($comments as $comment) {
$feed->addItem([
'title' => 'Comment on: ' . $comment['post_title'],
'author' => $comment['author_name'],
'link' => $this->baseUrl . '/posts/' . $comment['post_slug'] . '#comment-' . $comment['id'],
'pubdate' => $comment['created_at'],
'description' => $comment['content'],
'guid' => $this->baseUrl . '/comments/' . $comment['id']
]);
}
return $feed->render('rss');
}
public function generateJsonFeed(int $limit = 20): string
{
$posts = $this->getLatestPosts($limit);
$jsonFeed = [
'version' => 'https://jsonfeed.org/version/1.1',
'title' => $this->siteName,
'description' => 'Latest blog posts from ' . $this->siteName,
'home_page_url' => $this->baseUrl,
'feed_url' => $this->baseUrl . '/feed.json',
'language' => 'en',
'items' => []
];
foreach ($posts as $post) {
$item = [
'id' => $this->baseUrl . '/posts/' . $post['slug'],
'title' => $post['title'],
'content_html' => $post['content'],
'summary' => $post['excerpt'] ?: $this->generateExcerpt($post['content']),
'url' => $this->baseUrl . '/posts/' . $post['slug'],
'date_published' => date('c', strtotime($post['published_at'])),
'authors' => [
['name' => $post['author_name']]
]
];
if ($post['categories']) {
$item['tags'] = explode(',', $post['categories']);
}
if ($post['featured_image']) {
$item['image'] = $this->baseUrl . '/' . $post['featured_image'];
}
$jsonFeed['items'][] = $item;
}
return json_encode($jsonFeed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
private function getLatestPosts(int $limit): array
{
$sql = "
SELECT
p.id, p.title, p.slug, p.content, p.excerpt, p.published_at, p.featured_image,
a.name as author_name,
GROUP_CONCAT(c.name ORDER BY c.name) as categories
FROM posts p
JOIN authors a ON p.author_id = a.id
LEFT JOIN post_categories pc ON p.id = pc.post_id
LEFT JOIN categories c ON pc.category_id = c.id
WHERE p.published = 1 AND p.published_at <= NOW()
GROUP BY p.id
ORDER BY p.published_at DESC
LIMIT ?
";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$limit]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
private function getPostsByCategory(int $categoryId, int $limit): array
{
$sql = "
SELECT
p.id, p.title, p.slug, p.content, p.excerpt, p.published_at, p.featured_image,
a.name as author_name
FROM posts p
JOIN authors a ON p.author_id = a.id
JOIN post_categories pc ON p.id = pc.post_id
WHERE p.published = 1 AND p.published_at <= NOW()
AND pc.category_id = ?
ORDER BY p.published_at DESC
LIMIT ?
";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$categoryId, $limit]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
private function getPostsByAuthor(int $authorId, int $limit): array
{
$sql = "
SELECT
p.id, p.title, p.slug, p.content, p.excerpt, p.published_at, p.featured_image,
GROUP_CONCAT(c.name ORDER BY c.name) as categories
FROM posts p
LEFT JOIN post_categories pc ON p.id = pc.post_id
LEFT JOIN categories c ON pc.category_id = c.id
WHERE p.published = 1 AND p.published_at <= NOW()
AND p.author_id = ?
GROUP BY p.id
ORDER BY p.published_at DESC
LIMIT ?
";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$authorId, $limit]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
private function getLatestComments(?int $postId, int $limit): array
{
$sql = "
SELECT
c.id, c.author_name, c.content, c.created_at,
p.title as post_title, p.slug as post_slug
FROM comments c
JOIN posts p ON c.post_id = p.id
WHERE c.approved = 1
";
$params = [];
if ($postId) {
$sql .= " AND c.post_id = ?";
$params[] = $postId;
}
$sql .= " ORDER BY c.created_at DESC LIMIT ?";
$params[] = $limit;
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
private function getCategoryBySlug(string $slug): ?array
{
$stmt = $this->pdo->prepare("SELECT * FROM categories WHERE slug = ?");
$stmt->execute([$slug]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
private function getAuthorById(int $id): ?array
{
$stmt = $this->pdo->prepare("SELECT * FROM authors WHERE id = ?");
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
private function getPostById(int $id): ?array
{
$stmt = $this->pdo->prepare("SELECT * FROM posts WHERE id = ? AND published = 1");
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
private function generateExcerpt(string $content, int $length = 200): string
{
$text = strip_tags($content);
if (strlen($text) <= $length) {
return $text;
}
return substr($text, 0, $length) . '...';
}
}
// Usage example
try {
$pdo = new PDO('mysql:host=localhost;dbname=blog', $username, $password);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$blogFeed = new BlogFeedManager($pdo, 'https://myblog.com', 'My Awesome Blog');
// Route handling
$path = $_SERVER['REQUEST_URI'] ?? '/';
header('Content-Type: application/rss+xml; charset=utf-8');
if ($path === '/feed.xml' || $path === '/feed') {
echo $blogFeed->generateMainFeed();
} elseif ($path === '/feed.json') {
header('Content-Type: application/json; charset=utf-8');
echo $blogFeed->generateJsonFeed();
} elseif (preg_match('/^\/feed\/category\/([^\/]+)\.xml$/', $path, $matches)) {
echo $blogFeed->generateCategoryFeed($matches[1]);
} elseif (preg_match('/^\/feed\/author\/(\d+)\.xml$/', $path, $matches)) {
echo $blogFeed->generateAuthorFeed((int)$matches[1]);
} elseif ($path === '/feed/comments.xml') {
echo $blogFeed->generateCommentsFeed();
} elseif (preg_match('/^\/feed\/comments\/(\d+)\.xml$/', $path, $matches)) {
echo $blogFeed->generateCommentsFeed((int)$matches[1]);
} else {
http_response_code(404);
echo '<?xml version="1.0"?><error>Feed not found</error>';
}
} catch (Exception $e) {
http_response_code(500);
echo '<?xml version="1.0"?><error>Feed generation failed</error>';
error_log('Feed error: ' . $e->getMessage());
}
<?php
// app/Models/Post.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Post extends Model
{
protected $fillable = [
'title', 'slug', 'content', 'excerpt', 'author_id',
'featured_image', 'published', 'published_at'
];
protected $casts = [
'published' => 'boolean',
'published_at' => 'datetime'
];
public function author(): BelongsTo
{
return $this->belongsTo(Author::class);
}
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class, 'post_categories');
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function scopePublished($query)
{
return $query->where('published', true)
->where('published_at', '<=', now());
}
public function getExcerptAttribute($value): string
{
return $value ?: Str::limit(strip_tags($this->content), 200);
}
public function getUrlAttribute(): string
{
return route('posts.show', $this->slug);
}
}
<?php
// app/Models/Category.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Category extends Model
{
protected $fillable = ['name', 'slug', 'description'];
public function posts(): BelongsToMany
{
return $this->belongsToMany(Post::class, 'post_categories');
}
public function getUrlAttribute(): string
{
return route('categories.show', $this->slug);
}
}
<?php
// app/Http/Controllers/FeedController.php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\Category;
use App\Models\Author;
use App\Models\Comment;
use Illuminate\Http\Controller;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Rumenx\Feed\FeedFactory;
class FeedController extends Controller
{
public function main(): Response
{
$feedXml = Cache::remember('feed:main', now()->addHour(), function () {
return $this->generateMainFeed();
});
return response($feedXml)
->header('Content-Type', 'application/rss+xml; charset=utf-8');
}
public function category(string $slug): Response
{
$category = Category::where('slug', $slug)->firstOrFail();
$feedXml = Cache::remember("feed:category:{$slug}", now()->addHour(), function () use ($category) {
return $this->generateCategoryFeed($category);
});
return response($feedXml)
->header('Content-Type', 'application/rss+xml; charset=utf-8');
}
public function author(Author $author): Response
{
$feedXml = Cache::remember("feed:author:{$author->id}", now()->addHour(), function () use ($author) {
return $this->generateAuthorFeed($author);
});
return response($feedXml)
->header('Content-Type', 'application/rss+xml; charset=utf-8');
}
public function comments(?Post $post = null): Response
{
$cacheKey = $post ? "feed:comments:post:{$post->id}" : 'feed:comments:all';
$feedXml = Cache::remember($cacheKey, now()->addMinutes(15), function () use ($post) {
return $this->generateCommentsFeed($post);
});
return response($feedXml)
->header('Content-Type', 'application/rss+xml; charset=utf-8');
}
public function json(): Response
{
$feedJson = Cache::remember('feed:json', now()->addHour(), function () {
return $this->generateJsonFeed();
});
return response($feedJson)
->header('Content-Type', 'application/json; charset=utf-8');
}
private function generateMainFeed(): string
{
$feed = FeedFactory::create();
$feed->setTitle(config('app.name'));
$feed->setDescription('Latest blog posts from ' . config('app.name'));
$feed->setLink(url('/'));
$posts = Post::with(['author', 'categories'])
->published()
->latest('published_at')
->limit(20)
->get();
foreach ($posts as $post) {
$feed->addItem([
'title' => $post->title,
'author' => $post->author->name,
'link' => $post->url,
'pubdate' => $post->published_at,
'description' => $post->excerpt,
'content' => $post->content,
'category' => $post->categories->pluck('name')->toArray(),
'guid' => $post->url,
'image' => $post->featured_image ? asset($post->featured_image) : null
]);
}
return $feed->render('rss');
}
private function generateCategoryFeed(Category $category): string
{
$feed = FeedFactory::create();
$feed->setTitle(config('app.name') . ' - ' . $category->name);
$feed->setDescription('Latest posts from ' . $category->name . ' category');
$feed->setLink($category->url);
$posts = $category->posts()
->with(['author'])
->published()
->latest('published_at')
->limit(20)
->get();
foreach ($posts as $post) {
$feed->addItem([
'title' => $post->title,
'author' => $post->author->name,
'link' => $post->url,
'pubdate' => $post->published_at,
'description' => $post->excerpt,
'content' => $post->content,
'category' => [$category->name],
'guid' => $post->url
]);
}
return $feed->render('rss');
}
private function generateAuthorFeed(Author $author): string
{
$feed = FeedFactory::create();
$feed->setTitle(config('app.name') . ' - Posts by ' . $author->name);
$feed->setDescription('Latest posts by ' . $author->name);
$feed->setLink(route('authors.show', $author));
$posts = $author->posts()
->with(['categories'])
->published()
->latest('published_at')
->limit(20)
->get();
foreach ($posts as $post) {
$feed->addItem([
'title' => $post->title,
'author' => $author->name,
'link' => $post->url,
'pubdate' => $post->published_at,
'description' => $post->excerpt,
'content' => $post->content,
'category' => $post->categories->pluck('name')->toArray(),
'guid' => $post->url
]);
}
return $feed->render('rss');
}
private function generateCommentsFeed(?Post $post = null): string
{
$feed = FeedFactory::create();
if ($post) {
$feed->setTitle('Comments for: ' . $post->title);
$feed->setDescription('Latest comments on ' . $post->title);
$feed->setLink($post->url . '#comments');
$comments = $post->comments()
->where('approved', true)
->latest()
->limit(50)
->get();
} else {
$feed->setTitle(config('app.name') . ' - Latest Comments');
$feed->setDescription('Latest comments from ' . config('app.name'));
$feed->setLink(url('/comments'));
$comments = Comment::with('post')
->where('approved', true)
->latest()
->limit(50)
->get();
}
foreach ($comments as $comment) {
$feed->addItem([
'title' => 'Comment on: ' . $comment->post->title,
'author' => $comment->author_name,
'link' => $comment->post->url . '#comment-' . $comment->id,
'pubdate' => $comment->created_at,
'description' => $comment->content,
'guid' => url('/comments/' . $comment->id)
]);
}
return $feed->render('rss');
}
private function generateJsonFeed(): string
{
$posts = Post::with(['author', 'categories'])
->published()
->latest('published_at')
->limit(20)
->get();
$jsonFeed = [
'version' => 'https://jsonfeed.org/version/1.1',
'title' => config('app.name'),
'description' => 'Latest blog posts from ' . config('app.name'),
'home_page_url' => url('/'),
'feed_url' => route('feeds.json'),
'language' => 'en',
'items' => []
];
foreach ($posts as $post) {
$item = [
'id' => $post->url,
'title' => $post->title,
'content_html' => $post->content,
'summary' => $post->excerpt,
'url' => $post->url,
'date_published' => $post->published_at->toISOString(),
'authors' => [
['name' => $post->author->name]
]
];
if ($post->categories->isNotEmpty()) {
$item['tags'] = $post->categories->pluck('name')->toArray();
}
if ($post->featured_image) {
$item['image'] = asset($post->featured_image);
}
$jsonFeed['items'][] = $item;
}
return json_encode($jsonFeed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
}
<?php
// routes/web.php
use App\Http\Controllers\FeedController;
// Main feeds
Route::get('/feed.xml', [FeedController::class, 'main'])->name('feeds.main');
Route::get('/feed', [FeedController::class, 'main']);
Route::get('/feed.json', [FeedController::class, 'json'])->name('feeds.json');
// Category feeds
Route::get('/feed/category/{category:slug}.xml', [FeedController::class, 'category'])->name('feeds.category');
// Author feeds
Route::get('/feed/author/{author}.xml', [FeedController::class, 'author'])->name('feeds.author');
// Comment feeds
Route::get('/feed/comments.xml', [FeedController::class, 'comments'])->name('feeds.comments');
Route::get('/feed/comments/{post}.xml', [FeedController::class, 'comments'])->name('feeds.post_comments');
<?php
// app/Listeners/InvalidateFeedCache.php
namespace App\Listeners;
use Illuminate\Support\Facades\Cache;
use App\Events\PostPublished;
use App\Events\PostUpdated;
use App\Events\CommentApproved;
class InvalidateFeedCache
{
public function handle(PostPublished|PostUpdated|CommentApproved $event): void
{
// Clear main feed cache
Cache::forget('feed:main');
Cache::forget('feed:json');
if ($event instanceof PostPublished || $event instanceof PostUpdated) {
$post = $event->post;
// Clear author feed cache
Cache::forget("feed:author:{$post->author_id}");
// Clear category feeds
foreach ($post->categories as $category) {
Cache::forget("feed:category:{$category->slug}");
}
}
if ($event instanceof CommentApproved) {
// Clear comment feeds
Cache::forget('feed:comments:all');
Cache::forget("feed:comments:post:{$event->comment->post_id}");
}
}
}
<?php
class SeoFeedHeaders
{
public static function setHeaders(string $feedUrl, int $cacheTime = 3600): void
{
header('Content-Type: application/rss+xml; charset=utf-8');
header("Cache-Control: public, max-age={$cacheTime}");
header('ETag: "' . md5($feedUrl . date('Y-m-d-H')) . '"');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT');
header('Vary: Accept-Encoding');
// Feed discovery
header('Link: <' . $feedUrl . '>; rel="self"; type="application/rss+xml"');
}
}
<?php
class FeedAnalytics
{
public static function trackFeedAccess(string $feedType, ?string $userAgent = null): void
{
$userAgent = $userAgent ?: ($_SERVER['HTTP_USER_AGENT'] ?? 'Unknown');
// Detect feed readers
$feedReader = 'Unknown';
if (strpos($userAgent, 'Feedly') !== false) {
$feedReader = 'Feedly';
} elseif (strpos($userAgent, 'NewsBlur') !== false) {
$feedReader = 'NewsBlur';
} elseif (strpos($userAgent, 'Inoreader') !== false) {
$feedReader = 'Inoreader';
}
// Log to database or analytics service
DB::table('feed_analytics')->insert([
'feed_type' => $feedType,
'user_agent' => $userAgent,
'feed_reader' => $feedReader,
'ip_address' => request()->ip(),
'accessed_at' => now()
]);
}
}
<?php
class FeedSitemap
{
public static function generate(): string
{
$feeds = [
'https://myblog.com/feed.xml',
'https://myblog.com/feed.json',
'https://myblog.com/feed/comments.xml'
];
// Add category feeds
$categories = Category::all();
foreach ($categories as $category) {
$feeds[] = route('feeds.category', $category->slug);
}
// Add author feeds
$authors = Author::all();
foreach ($authors as $author) {
$feeds[] = route('feeds.author', $author);
}
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
foreach ($feeds as $feed) {
$xml .= '<url>' . "\n";
$xml .= '<loc>' . htmlspecialchars($feed) . '</loc>' . "\n";
$xml .= '<changefreq>daily</changefreq>' . "\n";
$xml .= '<priority>0.8</priority>' . "\n";
$xml .= '</url>' . "\n";
}
$xml .= '</urlset>';
return $xml;
}
}
<?php
// tests/Feature/BlogFeedTest.php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\Post;
use App\Models\Author;
use App\Models\Category;
use Illuminate\Foundation\Testing\RefreshDatabase;
class BlogFeedTest extends TestCase
{
use RefreshDatabase;
public function test_main_feed_generation(): void
{
$author = Author::factory()->create();
$category = Category::factory()->create();
$post = Post::factory()->create([
'author_id' => $author->id,
'published' => true,
'published_at' => now()
]);
$post->categories()->attach($category);
$response = $this->get('/feed.xml');
$response->assertStatus(200);
$response->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8');
$xml = simplexml_load_string($response->getContent());
$this->assertEquals(config('app.name'), (string)$xml->channel->title);
$this->assertEquals($post->title, (string)$xml->channel->item[0]->title);
}
public function test_category_feed_generation(): void
{
$category = Category::factory()->create(['slug' => 'technology']);
$author = Author::factory()->create();
$post = Post::factory()->create([
'author_id' => $author->id,
'published' => true,
'published_at' => now()
]);
$post->categories()->attach($category);
$response = $this->get("/feed/category/{$category->slug}.xml");
$response->assertStatus(200);
$xml = simplexml_load_string($response->getContent());
$this->assertStringContainsString($category->name, (string)$xml->channel->title);
$this->assertEquals($post->title, (string)$xml->channel->item[0]->title);
}
public function test_json_feed_generation(): void
{
$author = Author::factory()->create();
$post = Post::factory()->create([
'author_id' => $author->id,
'published' => true,
'published_at' => now()
]);
$response = $this->get('/feed.json');
$response->assertStatus(200);
$response->assertHeader('Content-Type', 'application/json; charset=utf-8');
$json = $response->json();
$this->assertEquals('https://jsonfeed.org/version/1.1', $json['version']);
$this->assertEquals($post->title, $json['items'][0]['title']);
}
}
- Performance: Always cache feed generation
- SEO: Include proper meta tags and feed discovery links
- Content: Provide both excerpt and full content options
- Categories: Organize feeds by categories and tags
- Comments: Consider providing comment feeds
- Analytics: Track feed usage to understand your audience
- Validation: Regularly validate feed XML/JSON structure
- Security: Sanitize content to prevent XSS
- Mobile: Ensure feeds work well with mobile feed readers
- Standards: Follow RSS 2.0, Atom 1.0, and JSON Feed specifications
- Learn about Feed Links for proper discovery implementation
- Check Caching for performance optimization
- See Advanced Features for enterprise-level functionality