A lightweight PHP application that generates RSS feeds for providers that don't generate their own.
For supported list, see parsers.
All local work runs inside Docker.
# Start a live dev server at http://localhost:8080
docker compose -f docker/docker-compose.yml up server
# Run golden (fixture-driven) tests
docker compose -f docker/docker-compose.yml run --rm test-golden
# Run live (network) sanity tests
docker compose -f docker/docker-compose.yml run --rm test-liveThe dev server reads the token from the FEED_TOKEN environment variable,
which is set to dev in docker-compose.yml. Visit the feed index or pull
a specific feed:
http://localhost:8080/?token=dev # HTML index listing all feeds
http://localhost:8080/ac-movies?token=dev # specific feed
Copy config.php.example to config.php and set a secret token:
define('FEED_TOKEN', 'your-secret-here');config.php is never committed. In the Docker dev environment, FEED_TOKEN=dev
is injected automatically.
Every request must include the token via either:
- Authorization header:
Authorization: Bearer <token> - Query parameter:
?token=<token>(URL-encode the value)
# Deploy to a remote server (copies config.php by default)
./deploy.sh [email protected]:public_html/feeds
# Deploy without overwriting an existing config.php on the server
./deploy.sh --skip-config [email protected]:public_html/feeds
# Deploy to a local directory
./deploy.sh /var/www/html/feedsRequires SSH key-based access. Sets 755 on directories and 644 on files
after transfer.
Add src/parsers/YourParser.php implementing Grouch\Contract\ParserInterface.
Declare a ROUTE constant for the URL path segment the feed will be served at:
<?php
declare(strict_types=1);
namespace Grouch\parsers;
use DateTimeImmutable;
use Grouch\Contract\ParseEntry;
use Grouch\Contract\ParserInterface;
use Grouch\Contract\ParseResult;
class YourParser implements ParserInterface
{
public const string ROUTE = 'your-feed';
public function parse(string $feedUrl, callable $fetch): ParseResult
{
// $fetch is fn(string $url): string
// Use it to retrieve remote content — tests will inject a mock.
$body = $fetch('https://example.com/api/events.json');
$data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
$entries = [];
foreach ($data['events'] as $event) {
$entries[] = new ParseEntry(
guid: $event['id'],
title: $event['title'],
url: $event['url'],
publishedAt: new DateTimeImmutable($event['date']),
html: '<p>' . htmlspecialchars($event['body'], ENT_XML1) . '</p>',
summary: $event['excerpt'],
author: '',
);
}
return new ParseResult(
title: 'Your Feed Title',
feedUrl: $feedUrl,
siteUrl: 'https://example.com',
description: '',
entries: $entries,
);
}
}ParseEntry fields:
| Field | Type | Required | Notes |
|---|---|---|---|
guid |
string |
Yes | Stable, unique identifier for the item |
title |
string |
Yes | |
url |
string |
Yes | Canonical permalink |
publishedAt |
DateTimeInterface |
Yes | |
html |
string |
No | Rich body; takes precedence over summary in XML |
summary |
string |
No | Plain-text fallback when html is empty |
author |
string |
No |
Add an entry to the $parsers map in index.php:
$parsers = [
// existing parsers …
'your-feed' => ['class' => \Grouch\parsers\YourParser::class, 'name' => 'Your Feed Name'],
];name is the human-readable label shown in the HTML index and in feed reader subscription dialogs. The feed will be available at /your-feed?token=….
Capture a representative API response and save it under tests/fixtures/:
tests/fixtures/yourparser_source.json (or .html, depending on format)
docker compose -f docker/docker-compose.yml run --rm test-golden \
php tests/generate_fixtures.phpInspect tests/fixtures/yourparser_expected.json, then commit both fixtures.
// tests/YourParserGoldenTest.php
namespace Grouch\Tests;
use Grouch\parsers\YourParser;
class YourParserGoldenTest extends GoldenTestCase
{
protected function getResult(): \Grouch\Contract\ParseResult
{
$source = file_get_contents(__DIR__ . '/fixtures/yourparser_source.json');
$parser = new YourParser();
return $parser->parse('https://example.com/your-feed', fn($_) => $source);
}
protected function getExpectedFile(): string
{
return __DIR__ . '/fixtures/yourparser_expected.json';
}
}Register it in phpunit.xml:
<testsuite name="golden">
<!-- existing entries … -->
<file>tests/YourParserGoldenTest.php</file>
</testsuite>// tests/YourParserLiveTest.php
namespace Grouch\Tests;
use Grouch\Contract\ParserInterface;
use Grouch\parsers\YourParser;
class YourParserLiveTest extends LiveTestCase
{
protected function getParser(): ParserInterface
{
return new YourParser();
}
protected function getFeedUrl(): string
{
return 'https://example.com/your-feed';
}
}Register it in phpunit.xml under the live testsuite.
See [docs/plans/architecture.md](docs/plans/architecture.md).
Copyright 2026 Akop Karapetyan. Licensed under the Apache License, Version 2.0.