Skip to content

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

Description

@PicassoHouessou

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
{
    // ...
}
  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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions