sofascore/purgatory-bundle
A Symfony bundle from Sofascore for purging Doctrine entities after a configurable retention period. Define purge rules, schedule cleanup, and automatically remove stale or expired records with minimal configuration and Symfony-native integration.
First, the honest framing: this is a Symfony bundle, not a Laravel package. There is no service provider, no config/purgatory.php, no facade. As a Laravel developer you get value in exactly two ways:
sofascore/purgatory library directly in a Laravel job/command, wrapping it with your own container bindings.Either way, the mental model is the same: responses carry cache tags → something writes/persists → you call the purger with the affected tags → the cache backend (Varnish/nginx-xkey/CDN) drops every object carrying those tags.
Minimal steps (Symfony side, to understand the contract):
composer require sofascore/purgatory-bundle
// config/bundles.php
Sofascore\PurgatoryBundle\PurgatoryBundle::class => ['all' => true],
# config/packages/purgatory.yaml
purgatory:
driver:
varnish: # backend-specific block; check the README for your version
hosts:
- 'http://varnish:6081'
Then in a console command or controller:
php bin/console debug:container purgatory
php bin/console debug:container --tag=purgatory.purger
Do that first — class names and service ids have shifted between minor versions, and debug:container is faster than guessing from the README. Inject whatever the alias resolves to (the public contract is the purger interface), and call it with an array of tags.
Your first use case: make one already-cached page invalidatable. Add a tag header to that response (xkey for Varnish's xkey module, or whatever header your VCL maps to tags), confirm the object appears under that tag, then run a one-off purge(['product:42']) from bin/console and watch the object disappear. Do this before wiring it into any Doctrine listener — otherwise you'll be debugging three layers at once.
Laravel-side first step: mirror that experiment. Write a PurgeCacheTags job that POSTs tags to your purge endpoint (or calls the library), dispatch it manually from php artisan tinker, and verify the object vanishes. The integration is then just "dispatch that job from model events."
On v2.0.2: this is a pure bugfix patch (VarnishPurger transport-error handling, AsyncPurger empty-message guard, TraceablePurger key preservation). Nothing to migrate — but the VarnishPurger fix changes failure semantics, so read the first bullet under Gotchas before you build monitoring around exceptions.
1. Tag naming as a first-class contract. Decide the scheme before writing code and put it in one class:
final class CacheTags
{
public static function product(int $id): string { return "product-{$id}"; }
public static function productList(): string { return 'product-list'; }
public static function category(int $id): string { return "category-{$id}"; }
}
Emit the collection when rendering, purge the same collection when writing. Keep a per-response list (e.g. product-42, category-7, product-list) so a single write can invalidate detail pages, listing pages, and any aggregate they appear in.
2. Purge on write, after commit — never inside the transaction.
class Product extends Model
{
protected static function booted(): void
{
static::saved(function (Product $p) {
$tags = [CacheTags::product($p->id), CacheTags::productList()];
if ($p->wasChanged('category_id')) {
$tags[] = CacheTags::category($p->getOriginal('category_id'));
$tags[] = CacheTags::category($p->category_id);
}
PurgeCacheTags::dispatch(array_unique($tags));
});
}
}
Use DB::afterCommit() or dispatch from an observer that only runs post-commit. Purging inside a transaction is the classic race: the purger fires, a concurrent request re-caches the old row (still uncommitted-visibility), and the stale copy sticks around forever.
3. Batch and coalesce. A bulk import of 10k rows shouldn't produce 10k purge calls. Buffer tags in a request-scoped collector and flush once at the end of the job/request:
class TagCollector
{
private array $tags = [];
public function add(string ...$tags): void { foreach ($tags as $t) $this->tags[$t] = true; }
public function drain(): array { $t = array_keys($this->tags); $this->tags = []; return $t; }
}
Bind it scoped in Laravel, shared in Symfony, and flush in terminate() / kernel.terminate / queue job finally. Since v2.0.2 the bundle's own AsyncPurger no longer dispatches a message when the iterable is empty, so an empty drain() is cheap — but still guard with if ($tags === []) return; on your side rather than relying on it, and note this fix only covers the bundle's async path, not your own queue messages.
4. Decorator over the purger for cross-cutting concerns. Both ecosystems make this trivial and it's the right extension point:
final class LoggingPurger implements PurgerInterface
{
public function __construct(private PurgerInterface $inner, private LoggerInterface $log) {}
public function purge(array $tags): void
{
$this->log->info('purge', ['tags' => $tags]);
$this->inner->purge($tags);
}
}
Also use a decorator for: tag normalization, dropping a wildcard purge if any single tag is *, and short-circuiting when the list exceeds a sane batch size (split into chunks). This decorator is now also your error telemetry layer — see the transport-exception gotcha below.
5. Multi-layer invalidation. Typical Sofascore-style topology is CDN → Varnish → app. Purgatory handles the Varnish hop; the CDN hop usually needs its own API call. Model this as a chain: CompositePurger([$varnish, $cdn]), each with its own retry/timeout policy, and let one failing layer not abort the others (log and continue, or fail-safe).
6. Testing without a real Varnish. Bind a recording/fake purger in the test container and assert on the recorded tag set. This is the single highest-value habit:
$this->putJson('/api/products/42', [...])->assertOk();
$this->assertPurged(['product-42', 'product-list', 'category-7']);
For unit tests of the Symfony bundle itself, the driver interfaces are mockable; you rarely need a live cache. Pass a plain array (not a generator or a keyed iterator) when asserting tag order/content: the TraceablePurger fix in v2.0.2 removed key preservation from iterator_to_array, so previously a keyed/non-zero-indexed iterable could silently drop or reindex tags in the profiler trace. Upgrading changes profiler output for such inputs — nothing to fix, but don't be surprised if a debug assertion on exact keys shifts.
7. Symfony/Laravel bridge. If you keep the bundle: run it as a tiny long-running Symfony console worker consuming a queue (Redis/SQS). Laravel dispatches {tags: [...]} messages; the worker calls the purger. Benefits: exact upstream driver behavior, one place for credentials, easy to swap backends. Costs: a second runtime to deploy and monitor.
composer require it in a Laravel app you'll get a bundle class that does nothing. Either run Symfony alongside or use the core library with your own bindings — and be prepared to maintain that glue, because the bundle's BC policy won't cover your use case.VarnishPurger. TransportExceptionInterface is now caught instead of propagating (PR #173). That's the right call for availability — one slow Varnish host no longer blows up a request — but it means a failed purge can now look identical to a successful one. Do not build monitoring on "did an exception bubble out"; put logging inside a purger decorator, and keep metrics on purge count vs. observed 2xx. If you upgraded from ≤ v2.0.1 and your error dashboards suddenly went quiet, that's why, not because things got better.Cache::tags(). Laravel's cache tags are for Redis/Memcached/array stores and only work on that store's keys. Purgatory is about HTTP cache layers and tag headers that a reverse proxy understands. Different layer, different failure modes.Vary/rewrite rule. Verify with curl -I against the edge, not against PHP-FPM. If the header is missing at the edge, nothing else matters.product-1 and you might also nuke a product-1 from another domain if you share a cache. Always prefix by entity + optionally by tenant/locale, and keep the prefix logic in one class, not sprinkled in templates.Product-42 vs product-42 vs product-42 are three different tags to the proxy. Normalize on emit and on purge, or you'll ship silent cache misses that look like "purgatory doesn't work."created before an ID exists, and be careful with soft deletes and wasChanged() — getOriginal() gives the pre-save value, which is the one whose cache entry is stale.ban is async. A ban marks objects; actual eviction happens on the ban-lurker pass. Don't assert "gone" immediately in a health check, and expect bans to accumulate if you issue many. purge() (exact URL) is synchronous; ban/tag-based is not.debug:config purgatory rather than reading YAML.~2.0.2 is a safe floor given the transport fix), read the changelog before bumping, and keep your purger behind your own interface so a swap to plain curl/Http::post() (a ban request is often one line of VCL-matching HTTP) is a day's work, not a rewrite.How can I help you explore Laravel packages today?