Skip to content

Laravel: property metadata factories hit the Laravel cache store on every call, no in-memory memoization (unlike Symfony's CachedTrait) #8413

Description

@nstoeckel

API Platform version(s) affected: v4.3.17, code unchanged on main (api-platform/laravel)

Description

The Laravel metadata cache factories (CachePropertyMetadataFactory, CachePropertyNameCollectionMetadataFactory, CacheResourceCollectionMetadataFactory) delegate every create() call directly to the Laravel cache repository:

// src/Laravel/Metadata/CachePropertyMetadataFactory.php
public function create(string $resourceClass, string $property, array $options = []): ApiProperty
{
    $key = hash('xxh3', serialize(['resource_class' => $resourceClass, 'property' => $property] + $options));

    return Cache::store($this->cacheStore)->rememberForever($key, function () use ($resourceClass, $property, $options) {
        return $this->decorated->create($resourceClass, $property, $options);
    });
}

There is no in-memory layer, so every call pays the full Laravel cache stack even on a hit: facade and store resolution, serialize() + hash('xxh3') for the key, Repository::get() including a CacheHit event dispatched through the event dispatcher, and (with the default file store) a filesystem read plus unserialize() of the metadata object graph.

AbstractItemNormalizer looks property metadata up per attribute of every normalized item (getAllowedAttributes(), getAttributeValue(), ...), on the implicit assumption that the factory is O(1) after warmup. The Symfony implementation guarantees this: ApiPlatform\Metadata\Property\Factory\CachedPropertyMetadataFactory uses CachedTrait, which checks a plain $localCache array before touching the PSR-6 pool, so the slow path runs once per property per request. The Laravel port dropped that layer, so the slow path runs once per property per item.

Measured on v4.3.17 with a real project: serializing an unpaginated collection of 2,023 items (7 scalar properties, plain JSON) invokes the property metadata factory 48,640 times in a single request (~24 calls per item); including the name-collection and resource metadata factories, Xdebug shows ~56,000 Cache::rememberForever() round trips. Illuminate\Cache\Repository->get, the CacheHit event dispatching, and the key hashing dominate the profile.

Adding an in-memory memoizing decorator around PropertyMetadataFactoryInterface and PropertyNameCollectionFactoryInterface cuts serialization of that collection from 1.18s to 0.60s (2x) on v4.3.17. On smaller responses the overhead is proportionally the same, just hidden by default pagination (30 items ≈ 800 redundant cache round trips per request).

How to reproduce

  1. Define any #[ApiResource] Eloquent model with a GetCollection operation and a few thousand rows.
  2. Request the collection with pagination disabled (or a large itemsPerPage).
  3. Add a counter inside CachePropertyMetadataFactory::create() (or profile with Xdebug): it is invoked items × properties times per request instead of properties times, and each invocation goes through Illuminate\Cache\Repository and fires a CacheHit event.

Possible solution

Mirror the Symfony implementation: keep the persistent Laravel store for cross-request caching, but check a local array first, either by adding a private array $localCache = [] to the three Laravel factories or by reusing ApiPlatform\Metadata\Util\CachedTrait with a PSR-6 adapter over the Laravel store.

Related: #8337 / #8342 fixed the same class of problem (per-item cost that should be per-request) in Router::getRouteCollection().

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