API Platform version(s) affected: api-platform/laravel v4.3.15, api-platform/http-cache v4.3.17
Description
Two stacked bugs make HTTP cache invalidation silently a no-op for any resource with more than one GetCollection operation — confirmed end-to-end on a real deployment (not just locally), reproduced by disabling a Taxon and observing /api/taxa keep serving the stale (enabled:true) response until its TTL expired naturally, purge notwithstanding.
Bug 1 — AddTagsProcessor is never wired into the Laravel bridge
ApiPlatform\HttpCache\State\AddTagsProcessor (in api-platform/http-cache) is the class responsible for writing the Surrogate-Key / Cache-Tags response header onto cacheable responses at write time — this is what gives a purger something to match against later. Symfony's bundle wires this in automatically via a compiler pass when api_platform.http_cache.invalidation is configured. api-platform/laravel's ApiPlatformProvider never does this — grepping the whole vendor/api-platform/laravel tree, AddTagsProcessor is referenced nowhere.
Confirmed via curl on a real deployment: every cacheable collection/item response (/api/taxa, /api/homepage-categories) comes back with no Surrogate-Key header at all, regardless of a correctly-configured purger. Purge requests are sent, return 200, and do nothing — there's nothing tagged for them to invalidate.
Bug 2 — PurgeHttpCacheListener only tags one GetCollection operation per resource
ApiPlatform\Laravel\Eloquent\Listener\PurgeHttpCacheListener::handleModelSaved() / handleModelDeleted() build the tag for "the" collection endpoint of a resource like this:
$this->tags[] = $this->iriConverter->getIriFromResource($model::class, operation: new GetCollection(class: $model::class));
No operation name/uriTemplate is given, so IriConverter::getIriFromResource() falls back to ResourceMetadataCollection::getOperation(null, forceCollection: true), which by contract returns the first GetCollection operation found while iterating the resource's declared operations.
When a resource declares more than one GetCollection operation (a public catalog collection, an admin collection, a "for homepage" collection, each with its own uriTemplate — a common pattern), only the first-declared one ever gets tagged. Every save/delete purges that one collection's cache entry and silently leaves the others stale — no error, no warning; getIriFromResource resolves fine, just to the wrong (incomplete) operation.
Bug 2 is masked by Bug 1 in practice (nothing is tagged at all, so which operation gets tagged doesn't matter yet), but both need fixing — fixing Bug 1 alone would still leave multi-collection resources partially stale.
Related but distinct from #7965 / #7970 (sub-resource collections needing parent uriVariables — a different scenario, no sibling GetCollection operations involved). Also related but distinct from #8258 (SouinPurger hardcoding PURGE, which Caddy's cache-handler admin API doesn't handle for that verb) — that issue is about the purger sending the wrong HTTP verb; this one is about there being nothing to purge in the first place, upstream of that.
How to reproduce
#[ApiResource(
uriTemplate: '/homepage-categories',
operations: [
new GetCollection(provider: HomepageCategoryProvider::class),
],
)]
#[ApiResource(
operations: [new Get, new Patch, new Delete, new Post],
)]
#[GetCollection] // implicit default uriTemplate, e.g. /categories
class Category extends Model
{
// ...
}
- Enable HTTP cache invalidation (
api-platform.http_cache.invalidation) with any purger.
- Warm the cache for
GET /api/categories (or any cacheable collection/item endpoint).
- Inspect the response headers — no
Surrogate-Key header present (Bug 1).
- Update or delete a
Category. The purger receives a purge call and returns success, but the cached /api/categories response is untouched — confirmed by its Cache-Status ttl counting down normally rather than resetting, and the stale value still being served.
- If a
Surrogate-Key header were present (patched via the fix below), it would still only cover one GetCollection if the resource declares several (Bug 2) — confirmed via:
app(IriConverterInterface::class)->getIriFromResource(
Category::class,
operation: new GetCollection(class: Category::class)
);
// => always resolves to the first-declared GetCollection, e.g. "/api/homepage-categories"
Possible Solution
Both fixes below were implemented and tested working (locally and on a real deployment) as Laravel service-provider overrides — no vendor files touched.
Fix 1 — wire AddTagsProcessor into the processor chain via ProcessorInterface::class decoration, the same pattern api-platform/laravel already uses for ObjectMapperInputProcessor/ObjectMapperOutputProcessor:
if (!empty(config('api-platform.http_cache.invalidation'))) {
$this->app->extend(ProcessorInterface::class, function (ProcessorInterface $inner, Application $app) {
return new AddTagsProcessor(
$inner,
$app->make(IriConverterInterface::class),
$app->make(PurgerInterface::class),
);
});
}
Fix 2 — replace PurgeHttpCacheListener with a version that enumerates every CollectionOperationInterface operation declared for the resource instead of asking for a single arbitrary one:
final class CollectionAwarePurgeHttpCacheListener
{
private array $tags = [];
public function __construct(
private readonly PurgerInterface $purger,
private readonly IriConverterInterface $iriConverter,
private readonly ResourceClassResolverInterface $resourceClassResolver,
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory,
) {
}
public function handleModelSaved(string $eventName, array $data): void
{
$this->collectTags($data);
}
public function handleModelDeleted(string $eventName, array $data): void
{
$this->collectTags($data);
}
private function collectTags(array $data): void
{
foreach ($data as $model) {
if (!$this->resourceClassResolver->isResourceClass($model::class)) {
continue;
}
try {
$this->tags[] = $this->iriConverter->getIriFromResource($model);
} catch (InvalidArgumentException|ItemNotFoundException) {
// do nothing
}
foreach ($this->resourceMetadataCollectionFactory->create($model::class) as $resource) {
foreach ($resource->getOperations() ?? [] as $operation) {
if (!$operation instanceof CollectionOperationInterface) {
continue;
}
try {
$this->tags[] = $this->iriConverter->getIriFromResource($model::class, operation: $operation);
} catch (InvalidArgumentException|ItemNotFoundException) {
// do nothing
}
}
}
}
}
public function postFlush(): void
{
if (empty($this->tags)) {
return;
}
$this->purger->purge(array_values(array_unique($this->tags)));
$this->tags = [];
}
}
Rebound in place of the vendor listener via a container singleton() override in AppServiceProvider::register() (the vendor class is final, so this is a container-rebind, not a subclass):
$this->app->singleton(PurgeHttpCacheListener::class, function (Application $app) {
return new CollectionAwarePurgeHttpCacheListener(
$app->make(PurgerInterface::class),
$app->make(IriConverterInterface::class),
$app->make(ResourceClassResolverInterface::class),
$app->make(ResourceMetadataCollectionFactoryInterface::class),
);
});
Since [PurgeHttpCacheListener::class, 'handleModelSaved'] is resolved from the container by class-string at event-dispatch time (not statically typed), this override is picked up transparently everywhere the vendor listener would have been used.
Additional Context
Still present on main (checked api-platform/laravel's Eloquent/Listener/PurgeHttpCacheListener.php, unchanged; AddTagsProcessor still absent from the whole api-platform/laravel tree). Laravel 12, PHP 8.4.
API Platform version(s) affected: api-platform/laravel v4.3.15, api-platform/http-cache v4.3.17
Description
Two stacked bugs make HTTP cache invalidation silently a no-op for any resource with more than one
GetCollectionoperation — confirmed end-to-end on a real deployment (not just locally), reproduced by disabling aTaxonand observing/api/taxakeep serving the stale (enabled:true) response until its TTL expired naturally, purge notwithstanding.Bug 1 —
AddTagsProcessoris never wired into the Laravel bridgeApiPlatform\HttpCache\State\AddTagsProcessor(inapi-platform/http-cache) is the class responsible for writing theSurrogate-Key/Cache-Tagsresponse header onto cacheable responses at write time — this is what gives a purger something to match against later. Symfony's bundle wires this in automatically via a compiler pass whenapi_platform.http_cache.invalidationis configured.api-platform/laravel'sApiPlatformProvidernever does this — grepping the wholevendor/api-platform/laraveltree,AddTagsProcessoris referenced nowhere.Confirmed via
curlon a real deployment: every cacheable collection/item response (/api/taxa,/api/homepage-categories) comes back with noSurrogate-Keyheader at all, regardless of a correctly-configured purger. Purge requests are sent, return 200, and do nothing — there's nothing tagged for them to invalidate.Bug 2 —
PurgeHttpCacheListeneronly tags oneGetCollectionoperation per resourceApiPlatform\Laravel\Eloquent\Listener\PurgeHttpCacheListener::handleModelSaved()/handleModelDeleted()build the tag for "the" collection endpoint of a resource like this:No operation name/uriTemplate is given, so
IriConverter::getIriFromResource()falls back toResourceMetadataCollection::getOperation(null, forceCollection: true), which by contract returns the firstGetCollectionoperation found while iterating the resource's declared operations.When a resource declares more than one
GetCollectionoperation (a public catalog collection, an admin collection, a "for homepage" collection, each with its ownuriTemplate— a common pattern), only the first-declared one ever gets tagged. Every save/delete purges that one collection's cache entry and silently leaves the others stale — no error, no warning;getIriFromResourceresolves fine, just to the wrong (incomplete) operation.Bug 2 is masked by Bug 1 in practice (nothing is tagged at all, so which operation gets tagged doesn't matter yet), but both need fixing — fixing Bug 1 alone would still leave multi-collection resources partially stale.
Related but distinct from #7965 / #7970 (sub-resource collections needing parent
uriVariables— a different scenario, no siblingGetCollectionoperations involved). Also related but distinct from #8258 (SouinPurgerhardcodingPURGE, which Caddy's cache-handler admin API doesn't handle for that verb) — that issue is about the purger sending the wrong HTTP verb; this one is about there being nothing to purge in the first place, upstream of that.How to reproduce
#[ApiResource( uriTemplate: '/homepage-categories', operations: [ new GetCollection(provider: HomepageCategoryProvider::class), ], )] #[ApiResource( operations: [new Get, new Patch, new Delete, new Post], )] #[GetCollection] // implicit default uriTemplate, e.g. /categories class Category extends Model { // ... }api-platform.http_cache.invalidation) with any purger.GET /api/categories(or any cacheable collection/item endpoint).Surrogate-Keyheader present (Bug 1).Category. The purger receives a purge call and returns success, but the cached/api/categoriesresponse is untouched — confirmed by itsCache-Statusttlcounting down normally rather than resetting, and the stale value still being served.Surrogate-Keyheader were present (patched via the fix below), it would still only cover oneGetCollectionif the resource declares several (Bug 2) — confirmed via:Possible Solution
Both fixes below were implemented and tested working (locally and on a real deployment) as Laravel service-provider overrides — no vendor files touched.
Fix 1 — wire
AddTagsProcessorinto the processor chain viaProcessorInterface::classdecoration, the same patternapi-platform/laravelalready uses forObjectMapperInputProcessor/ObjectMapperOutputProcessor:Fix 2 — replace
PurgeHttpCacheListenerwith a version that enumerates everyCollectionOperationInterfaceoperation declared for the resource instead of asking for a single arbitrary one:Rebound in place of the vendor listener via a container
singleton()override inAppServiceProvider::register()(the vendor class isfinal, so this is a container-rebind, not a subclass):Since
[PurgeHttpCacheListener::class, 'handleModelSaved']is resolved from the container by class-string at event-dispatch time (not statically typed), this override is picked up transparently everywhere the vendor listener would have been used.Additional Context
Still present on
main(checkedapi-platform/laravel'sEloquent/Listener/PurgeHttpCacheListener.php, unchanged;AddTagsProcessorstill absent from the wholeapi-platform/laraveltree). Laravel 12, PHP 8.4.