HTTP cache invalidation silently a no-op for resources with multiple GetCollection operations (Laravel bridge)
Nessuno ha ancora preso questa issue.
Valutazione
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Idoneità per principianti
- 56/100
Direzione di ricerca
Inizia da ApiPlatformProvider di api-platform/laravel e Eloquent/Listener/PurgeHttpCacheListener.php, quindi esamina ApiPlatform\HttpCache\State\AddTagsProcessor. Riproduci il problema con gli esempi curl e multi-GetCollection, controllando gli header della risposta e lo stato della cache. Il lavoro è completato quando le risposte memorizzabili nella cache espongono i purge tag e le operazioni di salvataggio o eliminazione invalidano ogni endpoint di collection dichiarato.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
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 api-platform/core#7965 / #7970 (sub-resource collections needing parent uriVariables — a different scenario, no sibling GetCollection operations involved). Also related but distinct from api-platform/core#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-Keyheader present (Bug 1). - Update or delete a
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. - If a
Surrogate-Keyheader were present (patched via the fix below), it would still only cover oneGetCollectionif 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.
- Lingua principale
- PHP
- Stelle
- 2.6k
- Fork
- 982
- Merge medio
- 1g 16h
- PR unite (30g)
- 59
Guida per i contributori
Apri la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di api-platform/core
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 85/100
api-platform/core#8573 ·
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 70/100
api-platform/core#8571 ·
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 65/100
api-platform/core#8564 ·
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 84/100
api-platform/core#8495 ·
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
api-platform/core#8471 ·
Tutte le issue di api-platform/core
Issue simili
-
tooling
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
-
UX
Difficoltà 2/5 1-3 ore Idoneità per principianti 70/100
ProfessionalWiki/NeoWiki#1525 ·
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
-
bug customer-reported
Difficoltà 2/5 1-3 ore Idoneità per principianti 65/100
MagnaCapax/PMSS#949 ·
-
responsive-media kses allow-list strips clipPathUnits, collapsing objectBoundingBox clip paths Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 84/100
Automattic/static-site-importer#1829 ·