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
- Define any
#[ApiResource] Eloquent model with a GetCollection operation and a few thousand rows.
- Request the collection with pagination disabled (or a large
itemsPerPage).
- 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().
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 everycreate()call directly to the Laravel cache repository: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 aCacheHitevent dispatched through the event dispatcher, and (with the defaultfilestore) a filesystem read plusunserialize()of the metadata object graph.AbstractItemNormalizerlooks 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\CachedPropertyMetadataFactoryusesCachedTrait, which checks a plain$localCachearray 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, theCacheHitevent dispatching, and the key hashing dominate the profile.Adding an in-memory memoizing decorator around
PropertyMetadataFactoryInterfaceandPropertyNameCollectionFactoryInterfacecuts 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
#[ApiResource]Eloquent model with aGetCollectionoperation and a few thousand rows.itemsPerPage).CachePropertyMetadataFactory::create()(or profile with Xdebug): it is invokeditems × propertiestimes per request instead ofpropertiestimes, and each invocation goes throughIlluminate\Cache\Repositoryand fires aCacheHitevent.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 reusingApiPlatform\Metadata\Util\CachedTraitwith 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().