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.2
- ezyang/htmlpurifier: ^4.13
- guzzlehttp/guzzle: ^7.2
- laminas/laminas-feed: ^2.14
- 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
- mockery/mockery: ^1.6
- nunomaduro/collision: ^8.1
- phpunit/phpunit: ^12.0
This package is auto-updated.
Last update: 2026-05-14 23:14:57 UTC
README
Subscribe to blogs, news sites and podcasts and get it all delivered to your inbox once a day in your own personalised newsletter.
Requirements
- PHP 8.2+
- Node.js (for frontend assets)
- A database supported by Laravel (SQLite, MySQL, PostgreSQL)
- Laravel Herd (recommended for local development)
Getting started
composer install npm install cp .env.example .env php artisan key:generate php artisan migrate npm run build
Development
The site is served automatically by Laravel Herd at https://subworthy.test.
To process feeds and send issues, both the scheduler and queue worker must be running:
php artisan schedule:work # runs the scheduler every minute php artisan queue:work # processes queued jobs
To rebuild frontend assets:
npm run dev # dev server with HMR npm run build # production build
Testing
php artisan test # full test suite php artisan test --compact # compact output php artisan test --filter=ClassName # single test class or method
How it works
Content pipeline
Feed checking and issue delivery are entirely queue-driven. A scheduler fires every minute:
- Feeds whose
next_check_attime matches the current UTC minute are dispatched asCheckFeedjobs. CheckFeed— imports the RSS/Atom feed vialaminas/laminas-feed. Creates or updatesPostrecords. If the feed has afetcherclass configured, dispatchesFetchFullPostfor each new post. Advancesnext_check_atby one hour on success, or 15 minutes on failure.FetchFullPost— runs a customFetcherContractimplementation to scrape richer content (e.g.ProducthuntFetcherextracts data from the page's Next.js JSON). Stores result inPost.fetched_raw.- Users whose
delivery_time(UTC) matches the current minute, and whosedays_of_weekincludes today, receive aCreateDailyIssuejob. CreateDailyIssue— collects posts sincelast_delivered_at, runs them throughPostFilterService, stores surviving post IDs as JSON in a newIssuerecord, then dispatchesEmailDailyIssue.EmailDailyIssue— hydrates the issue's posts and sends theNewIssuemail notification.
Daily maintenance jobs prune Post and Issue records older than one month (pruned posts are archived to ArchivedPost as tombstones), and remove feeds that have no remaining subscribers.
Feed extensibility
Two fields on Feed allow per-feed customisation:
Feed.formatter— fully-qualified class implementingFormatterContract. Defaults toDefaultFormatter, which sanitises HTML via HTMLPurifier, addstarget="_blank"to links, and resolves relative image URLs.Feed.fetcher— fully-qualified class implementingFetcherContract. Run asFetchFullPostafterCheckFeed. SeeProducthuntFetcherfor an example.
Filtering
PostFilterService evaluates per-subscription Filter records against each post. A filter has field, operator, and pattern. The operator (e.g. contains, does_not_contain, regex) maps to a method via _camelCase dynamic dispatch. A matching filter returns true, which excludes the post from the issue.
Delivery schedule
User.delivery_time_local (e.g. 0800) combined with User.timezone is converted to a UTC delivery_time on save and stored as a 4-character string. The scheduler matches that string against the current UTC Hi-format time each minute. days_of_week is a string of ISO day numbers (1–7).
Data model
| Model | Notes |
|---|---|
User |
Auth, delivery schedule, timezone |
Feed |
Shared across users; holds RSS URL, optional formatter/fetcher class |
Subscription |
Joins User + Feed; optional title override; has many Filters |
Post |
Belongs to Feed; raw = original RSS HTML; fetched_raw = scraper output |
Issue |
Belongs to User; posts JSON column = included post IDs; posts_excluded = filtered-out IDs |
ArchivedPost |
Tombstone (feed_id + source_id) to prevent re-import after pruning |
ReadLater |
Joins User + Post for the read-later queue |
Filter |
Belongs to Subscription; field + operator + pattern |
Public access
/@{username}— public profile showing a user's issue archive/issue/{issue}— publicly viewable issue/link/{user}/{post}— link tracking redirect
Stack
| Layer | Technology |
|---|---|
| Framework | Laravel 13 |
| PHP | 8.2+ |
| Frontend | Bootstrap 5, Alpine.js (via Livewire), Vite |
| Reactive UI | Livewire 3 |
| Feed parsing | laminas/laminas-feed |
| HTML sanitisation | ezyang/htmlpurifier |
| HTTP | Guzzle 7, Symfony BrowserKit/HttpClient |
| Spam protection | spatie/laravel-honeypot |
| Testing | PHPUnit 12 |