Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Purgatory Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. You run a Symfony service/worker (or a small Symfony "cache-control" app) that owns Purgatory and exposes a purge endpoint or consumes a queue.
  2. You use the underlying 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.

Implementation Patterns

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.

Gotchas and Tips

  • It is not a Laravel package. No auto-discovery, no published config, no facade. If you 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.
  • v2.0.2 swallows transport errors in 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.
  • Don't confuse this with 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.
  • Header must survive the whole chain. Tag headers are routinely stripped by CDNs, load balancers, or a 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.
  • Tag header size limits. xkey/Varnish and most CDNs cap header length (~4–8 KB, sometimes less). A page with 500 tags is a real risk on listing pages. Mitigate: tag aggregates/collections rather than every entity id, and cap the collector per response.
  • Tag namespace collisions. Purge 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.
  • Case and whitespace normalization. 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."
  • Commit ordering / race conditions. As above: purge after commit. Also avoid purging from 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.
  • Purge-before-write vs purge-after-write. Some setups recommend purge → write → purge (double purge) to handle requests that read mid-write. If you see rare stale pages after writes, that's the pattern to reach for.
  • Idempotency and retries are your job. Purges are fire-and-forget HTTP calls. Combined with the new caught-transport-exception behaviour, a brief Varnish outage yields no error and a permanently stale object (until TTL). Wrap in a queue with backoff, log failures explicitly from your decorator, monitor purge failure rate, and consider a max TTL as a safety net so a lost purge is eventually self-healing.
  • Varnish 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.
  • Config quirks worth checking first: hosts vs. a single base URI (array vs. string varies by version), scheme/port in the host string, trailing slashes, and the HTTP client injection point (a custom client is how you add timeouts/proxies/auth). Dump the resolved config with debug:config purgatory rather than reading YAML.
  • Timeouts matter more than correctness here. A purge call sitting in the request path with the default timeout will take your app down when Varnish is slow. Always give the HTTP client a hard 1–2s timeout and prefer async dispatch for anything in a user-facing path. Now that transport exceptions are caught upstream, the only thing standing between you and a pile of 30-second requests is your client timeout — set it.
  • Extension points to prefer: decorator on the purger (cross-cutting + telemetry), custom driver if the built-in ones don't match your backend, and a listener/event on the Symfony side if you want purge telemetry. Avoid forking the bundle — the surface you need is small.
  • Version pinning. The index reports a release dated 2026-10-09 (future-dated, likely a metadata artifact) and the project is a modest ~119 stars. Treat it as low-bus factor: pin exact versions (~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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons