theprivateer / subworthy
Subscribe to blogs, news sites and podcasts and get it all delivered to your inbox once a day in your own personalised newsletter.
Requires
- php: ^8.3
- ezyang/htmlpurifier: ^4.13
- guzzlehttp/guzzle: ^7.2
- laminas/laminas-feed: ^2.14
- laravel/ai: ^0.7.0
- laravel/framework: ^13.0
- laravel/tinker: ^3.0
- league/uri: ^7.0
- livewire/livewire: ^4.0
- spatie/laravel-honeypot: ^4.5
- symfony/browser-kit: ^7.0
- symfony/dom-crawler: ^7.0
- symfony/http-client: ^8.0
- symfony/mailgun-mailer: ^8.0
Requires (Dev)
- fakerphp/faker: ^1.23
- laravel/boost: ^2.4
- laravel/pint: ^1.30
- mockery/mockery: ^1.6
- nunomaduro/collision: ^8.1
- phpunit/phpunit: ^12.0
This package is auto-updated.
Last update: 2026-08-09 00:32:03 UTC
README
Subworthy is a Laravel RSS and podcast aggregator that turns a set of subscribed feeds into a single personalised newsletter email. Users subscribe to feed URLs (individually or by uploading an OPML export), set the time and days they want their digest, apply per-subscription filters to drop content they don't care about, and receive one daily issue. Each issue is also kept as a browsable archive on a public profile page.
Features
- Feed subscriptions — subscribe by URL; if the URL isn't a feed, Laminas' feed-link discovery is used to find one, and multiple candidates are presented as a choice.
- OPML import — upload an
.opml/.xmlexport (up to 1 MB). Parsing, de-duplication, and per-feed validation happen on the queue, so nested folder structures import without blocking the request. - Scheduled daily digests — per-user delivery time, timezone, and days of the week. Users with a
pausedtimestamp are skipped by the scheduler. - Per-subscription filters —
contains,does not contain,equals,does not equal,regex, andregex no matchagainst any post field. A single matching filter excludes the post. - AI summaries and themes —
SummarisePostsends article content to the configured provider (vialaravel/ai) and stores a short excerpt plus 1–3 broad topic categories on the post. Summaries are discarded for posts under a word threshold; themes are kept for every post. - Custom fetchers and formatters — feeds can name a class to scrape richer content than the RSS body provides, or to change how the stored HTML is rendered.
- Read Later — save posts from an issue and revisit them at
/readlater. - Public archive —
/@{username}lists a user's issues; individual issues are publicly viewable; outbound links pass through a tracking redirect. - Automatic housekeeping — posts and issues are pruned after a month (pruned posts leave a tombstone so they aren't re-imported), and feeds with no subscribers are deleted.
posts:backfill-summariescommand — dispatch summary/theme generation for existing posts, interactively or via flags.
Project structure
app/
Actions/ SubscribeToFeed — shared feed + subscription creation
Ai/Agents/ PostSummariser — laravel/ai agent with structured output
Console/Commands/ BackfillPostSummaries
Fetchers/ FetcherContract, AbstractFetcher, ProducthuntFetcher
Filters/ PostFilterService — evaluates Filter records against a Post
Formatters/ FormatterContract, DefaultFormatter (HTMLPurifier + URL fixes)
Http/Controllers/ Feed, Subscription, Filter, Issue, ReadLater, Link, Home, Auth, User
Jobs/ CheckFeed, FetchFullPost, SummarisePost, CreateDailyIssue,
EmailDailyIssue, ProcessOpmlImport, ImportOpmlFeed,
RemoveUnsubscribedFeeds, RemoveUnsubscribedArticlesFromIssues
Livewire/ Article — inline expansion and read-later toggling
Models/ User, Feed, Subscription, Filter, Post, Issue, ArchivedPost, ReadLater
Notifications/ NewIssue — the digest email
Reader/ GuzzleClient — HTTP client for laminas-feed
View/ Layout components and the ReadLater view composer
functions.php flash() and timezone_list() helpers (autoloaded)
bootstrap/app.php Routing, middleware, and the every-minute scheduler
config/feeds.php Feed refresh and post-summary tuning
config/ai.php AI provider credentials and defaults
resources/scss/ Bootstrap 5 theme compiled by Vite
resources/views/ Blade templates, including mail/issue.blade.php
routes/web.php Application routes (auth routes in routes/auth.php)
tests/Feature/ The bulk of the suite — jobs, controllers, models, filters
Getting started
Prerequisites
- PHP 8.3 or newer (developed and tested on 8.4)
- Composer
- Node.js and npm
- SQLite, MySQL, or PostgreSQL
- Laravel Herd — recommended; it serves the site at
https://subworthy.test
Installation
git clone <repository-url> subworthy cd subworthy composer install npm install cp .env.example .env php artisan key:generate
Set your database connection in .env. For SQLite:
touch database/database.sqlite
DB_CONNECTION=sqlite
Then run the migrations and build the frontend:
php artisan migrate npm run build
Environment variables
| Variable | Required | Description |
|---|---|---|
APP_KEY |
Yes | Generated by php artisan key:generate. |
APP_URL |
Yes | Base URL — used for links in the digest email. |
DB_CONNECTION |
Yes | sqlite, mysql, or pgsql. |
DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD |
For MySQL/Postgres | Standard Laravel database credentials. |
QUEUE_CONNECTION |
Recommended | Use database (or Redis/SQS) in any real setup — the content pipeline is queue-driven, and sync makes feed checks run inline. |
MAIL_MAILER |
Yes | log is fine locally. mailgun is supported via symfony/mailgun-mailer. |
MAIL_FROM_ADDRESS, MAIL_FROM_NAME |
Yes | Sender identity for digests. |
MAILGUN_DOMAIN, MAILGUN_SECRET, MAILGUN_ENDPOINT |
If using Mailgun | Mailgun credentials. |
OPENAI_API_KEY |
Optional | Enables AI summaries and themes. Without it, SummarisePost returns early and posts fall back to their RSS preview. |
POST_SUMMARY_MIN_WORDS |
Optional | Posts shorter than this keep their themes but have the summary discarded. Default 50. |
POST_SUMMARY_MAX_CHARACTERS |
Optional | Content is truncated to this length before being sent to the provider. Default 12000. |
REFRESH_POSTS |
Optional | When true, scheduled CheckFeed runs re-import already-seen posts. Default false. |
HONEYPOT_ENABLED |
Optional | Spam protection on public forms. Default true; disabled during tests. |
config/ai.php defines providers for Anthropic, Gemini, Groq, Ollama, OpenRouter, xAI and others alongside OpenAI. OpenAI is the default (ai.default); switch it and set that provider's key to use a different one.
Running locally
Herd serves the site automatically at https://subworthy.test. Feeds are only imported and issues only sent while the scheduler and a queue worker are running:
php artisan schedule:work # runs the scheduler every minute php artisan queue:work # processes queued jobs
For frontend work:
npm run dev # Vite dev server with HMR npm run build # production build
Usage
Adding feeds
Sign up, verify your email, then add a feed URL from the home page. If the URL is a website rather than a feed, Subworthy looks for feed links on the page and either subscribes directly (one match) or asks you to choose. A Feed record is shared across all users who subscribe to the same URL; your Subscription holds your own title override and filters.
To bulk-import, upload an OPML file on the account page. The upload is stored briefly, parsed on the queue, and each xmlUrl is validated as a real feed before a subscription is created.
Delivery schedule
On the account page, pick a timezone, a delivery time in 15-minute slots, and the days of the week you want issues. delivery_time_local (Hi, e.g. 0800) is converted to a UTC delivery_time whenever the user is saved; the scheduler compares that string to the current UTC minute and checks that today's ISO day number appears in days_of_week.
Filters
Each subscription can carry any number of filters, made up of a post field, an operator, and a pattern. PostFilterService runs them when the issue is built — a post matching any filter is recorded in posts_excluded instead of posts.
Backfilling summaries
php artisan posts:backfill-summaries # interactive prompts php artisan posts:backfill-summaries --feed=all --limit=100 # non-interactive php artisan posts:backfill-summaries --feed=3 --force # re-summarise a feed
Without --force, only posts missing a summary or themes are dispatched. Posts with no stored content are skipped and reported. The command fails fast if the default provider has no API key.
Writing a fetcher
A fetcher scrapes content the feed doesn't include. Implement FetcherContract, then set the fully-qualified class name on Feed.fetcher:
namespace App\Fetchers; use App\Models\Post; class ExampleFetcher extends AbstractFetcher implements FetcherContract { public function fetch(Post $post) { // Scrape $post->url and write the result to $post->fetched_raw } }
CheckFeed dispatches FetchFullPost for feeds with a fetcher, and FetchFullPost dispatches SummarisePost afterwards so the summary is generated from the enriched content. See ProducthuntFetcher, which reads the page's __NEXT_DATA__ JSON.
Writing a formatter
A formatter turns stored HTML into what's rendered. Implement FormatterContract and set Feed.formatter; leaving it null uses DefaultFormatter, which purifies the HTML down to a small tag allowlist, adds target="_blank" to links, and rewrites root-relative image paths onto the feed's domain.
How it works
Content pipeline
The scheduler runs every minute and dispatches work from stored timestamps:
- Feeds whose
next_check_at(a 4-characterHistring) matches the current UTC minute are dispatched asCheckFeedjobs. CheckFeedimports the feed withlaminas/laminas-feedover a custom Guzzle client, creating or updatingPostrecords. Posts older than a month, and posts with a matchingArchivedPosttombstone, are skipped. It then advancesnext_check_atby an hour, or by 15 minutes if the import threw.FetchFullPostruns the feed'sFetcherContractimplementation and stores the result inPost.fetched_raw.SummarisePostsends the post's content to thePostSummariseragent and stores the returnedsummaryandthemes. It is dispatched byFetchFullPostwhen a fetcher exists, and directly byCheckFeedotherwise.- Users whose
delivery_timematches the current minute, whosedays_of_weekincludes today, and who aren't paused, receive aCreateDailyIssuejob. CreateDailyIssuecollects posts created sincelast_delivered_at(or the last two days for a first issue), splits them withPostFilterService, and — if anything survived — creates anIssueholding the post IDs as JSON, then dispatchesEmailDailyIssue.last_delivered_atadvances either way.EmailDailyIssuehydrates the issue viaIssue::loadIssue()and sends theNewIssuenotification, rendered fromresources/views/mail/issue.blade.php.
Daily jobs prune Post and Issue records older than a month and remove feeds with no remaining subscribers. Unsubscribing also dispatches RemoveUnsubscribedArticlesFromIssues, which strips that feed's posts from the user's existing issues.
Data model
| Model | Notes |
|---|---|
User |
Auth, delivery schedule, timezone, optional paused timestamp |
Feed |
Shared across users; URL, tld, optional fetcher/formatter class, next_check_at |
Subscription |
Joins User + Feed; optional title override; has many Filters |
Post |
Belongs to Feed; raw = original RSS HTML, fetched_raw = scraper output, summary + themes = AI output |
Issue |
Belongs to User; posts JSON = included post IDs, posts_excluded = filtered-out IDs |
ArchivedPost |
Tombstone (feed_id + source_id) preventing re-import after pruning |
ReadLater |
Joins User + Post |
Filter |
Belongs to Subscription; field + operator + pattern |
Public routes
/@{username}— public profile and issue archive/issue/{issue}— publicly viewable issue/link/{user}/{post}— tracked redirect to the original article
Testing
The suite is PHPUnit, split into tests/Unit and tests/Feature, with almost all coverage in the feature suite. Tests run against the connection configured in .env; phpunit.xml forces array cache/session, the array mailer, the sync queue, and disables the honeypot.
Most classes use RefreshDatabase, but a few (PostFilterServiceTest, PublicRoutesTest) do not and expect an already-migrated schema — so the suite needs a real, migrated database and will not run against DB_DATABASE=:memory:. Note that RefreshDatabase will wipe whatever database .env points at.
php artisan test # full suite php artisan test --compact # compact output php artisan test --filter=CheckFeedTest # a single class or method ./vendor/bin/phpunit # alternative runner
Building
npm run build # compiles resources/scss and resources/js into public/build
Frontend changes won't appear until either npm run dev or npm run build has run.
Code style
PHP is formatted with Laravel Pint, and StyleCI is configured with the laravel preset (no_unused_imports disabled).
./vendor/bin/pint # format everything ./vendor/bin/pint --dirty # only changed files ./vendor/bin/pint --test # check without writing
Continuous integration
.github/workflows/ci.yml runs on pushes to main and on every pull request:
- Pint (report only) — runs
./vendor/bin/pint --testand writes the result to the job summary. Style violations never fail the build. - PHPUnit — installs dependencies, builds the frontend (the Blade layouts use
@vite, so views can't render without a manifest), creates and migrates a SQLite database, then runsphp artisan test --compact.
There is no deployment pipeline; CI only lints and tests.
Deployment
Deployment is manual. At minimum it needs:
composer install --no-dev --optimize-autoloaderandnpm ci && npm run buildphp artisan migrate --forcephp artisan config:cache route:cache view:cache(orphp artisan optimize)- A persistent queue worker (
php artisan queue:work) and a cron entry runningphp artisan schedule:runevery minute — without both, no feeds are checked and no issues are sent APP_DEBUG=falseand a real mail transport
Contributing
There is no CONTRIBUTING.md. Match the existing conventions: run Pint before committing, and add or update tests alongside any behaviour change. CI reports style issues but only fails on test failures.
License
Released under the MIT License.