Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

HTTP cache invalidation silently a no-op for resources with multiple GetCollection operations (Laravel bridge)

Aperta
#8,452 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
56/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Tranquilla
Stack tecnologico
laravel, php
Ambito
api, backend

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
{
    // ...
}
  1. Enable HTTP cache invalidation (api-platform.http_cache.invalidation) with any purger.
  2. Warm the cache for GET /api/categories (or any cacheable collection/item endpoint).
  3. Inspect the response headers — no Surrogate-Key header present (Bug 1).
  4. 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.
  5. 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.

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

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di api-platform/core

Tutte le issue di api-platform/core

Issue simili

Altre issue su PHP

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.