API Platform version(s) affected: v4.3.14 (api-platform/laravel)
Description
ApiPlatform\Laravel\Routing\Router::generate() rebuilds the entire Symfony RouteCollection (every Laravel route converted into a Symfony Route object) on every single call, with no memoization:
// vendor/api-platform/laravel/Routing/Router.php
public function generate(string $name, array $parameters = [], ?int $referenceType = null): string
{
$routes = $this->getRouteCollection(); // rebuilds the whole table, every time
$generator = new UrlGenerator($routes, $this->getContext());
// ...
}
public function getRouteCollection(): RouteCollection
{
/** @var \Illuminate\Routing\RouteCollection $routes */
$routes = $this->router->getRoutes();
return $routes->toSymfonyRouteCollection(); // no caching here either
}
generate() is called once per IRI generated during normalization — i.e. once per relation/sub-resource reference in a JSON-LD response, via ApiPlatform\Laravel\Routing\IriConverter::generateRoute(). So a single response with several relations triggers the full route-table conversion once per relation, not once per response.
Confirmed via Xdebug profiling: serializing a single resource with ~8 relations triggered Illuminate\Routing\AbstractRouteCollection::toSymfonyRouteCollection() 65 times in one request — once per relation, rebuilding the full route table each time. For a 20-item collection this multiplies further. This dominates response time on any resource with several relations.
php artisan route:cache has no effect — the rebuild happens after Laravel's own route loading, so caching Laravel's route table doesn't touch this code path.
How to reproduce
- Define an
#[ApiResource] with a handful of relations embedded in its normalization group (HasMany/BelongsTo, each tagged into the read group so they get embedded/IRI-linked).
GET the collection endpoint for that resource with several items, each carrying those relations.
- Add a counter (or profile with Xdebug) inside
Illuminate\Routing\AbstractRouteCollection::toSymfonyRouteCollection() — observe it being invoked dozens of times for a single request, once per relation/IRI rather than once per response.
Minimal repro outline:
#[ApiResource]
#[ApiProperty(serialize: new Groups(['item:read']), property: 'relationA')]
#[ApiProperty(serialize: new Groups(['item:read']), property: 'relationB')]
// ... a few more relations
class Item extends Model
{
public function relationA(): BelongsTo { /* ... */ }
public function relationB(): BelongsTo { /* ... */ }
}
GET /api/items?itemsPerPage=20 with each item carrying those relations reproduces the issue.
Possible Solution
Router::generate()'s underlying route collection doesn't change mid-request — routes are static. Caching the built RouteCollection on the Router instance fixes it:
final class Router implements RouterInterface, UrlGeneratorInterface
{
private ?RouteCollection $routeCollection = null;
public function getRouteCollection(): RouteCollection
{
return $this->routeCollection ??= $this->router->getRoutes()->toSymfonyRouteCollection();
}
// generate() unchanged, just benefits from the now-cached getRouteCollection()
}
We validated this exact fix (via a userland decorator, since Router is final and can't be subclassed/patched directly — we wrap it and rebind the two consumers that resolve it: the UrlGeneratorInterface alias, and IriConverter::class, which injects the concrete Router directly rather than via the interface):
toSymfonyRouteCollection() call count: ~65 → 1-2 per request.
- Real wall-clock improvement: ~15-25% on endpoints with several relations.
- Zero test regressions.
Additional Context
IriConverter (vendor/api-platform/laravel/Routing/IriConverter.php) is constructed in ApiPlatformProvider::register() with the concrete Router instance injected directly (typed as Symfony\Component\Routing\RouterInterface), bypassing the ApiPlatform\Metadata\UrlGeneratorInterface binding entirely — so a fix needs to land in Router itself (or IriConverter needs to stop depending on the concrete class) rather than just decorating the interface binding.
SkolemIriConverter (vendor/api-platform/laravel/Routing/SkolemIriConverter.php) strictly type-hints the literal concrete Router class in its constructor, which is why a userland fix can't simply swap the Router::class container binding for a wrapper — any replacement must remain instanceof Router, which isn't possible since the class is final. This is itself worth fixing upstream (either make Router non-final, or have SkolemIriConverter depend on an interface).
API Platform version(s) affected: v4.3.14 (
api-platform/laravel)Description
ApiPlatform\Laravel\Routing\Router::generate()rebuilds the entire SymfonyRouteCollection(every Laravel route converted into a SymfonyRouteobject) on every single call, with no memoization:generate()is called once per IRI generated during normalization — i.e. once per relation/sub-resource reference in a JSON-LD response, viaApiPlatform\Laravel\Routing\IriConverter::generateRoute(). So a single response with several relations triggers the full route-table conversion once per relation, not once per response.Confirmed via Xdebug profiling: serializing a single resource with ~8 relations triggered
Illuminate\Routing\AbstractRouteCollection::toSymfonyRouteCollection()65 times in one request — once per relation, rebuilding the full route table each time. For a 20-item collection this multiplies further. This dominates response time on any resource with several relations.php artisan route:cachehas no effect — the rebuild happens after Laravel's own route loading, so caching Laravel's route table doesn't touch this code path.How to reproduce
#[ApiResource]with a handful of relations embedded in its normalization group (HasMany/BelongsTo, each tagged into the read group so they get embedded/IRI-linked).GETthe collection endpoint for that resource with several items, each carrying those relations.Illuminate\Routing\AbstractRouteCollection::toSymfonyRouteCollection()— observe it being invoked dozens of times for a single request, once per relation/IRI rather than once per response.Minimal repro outline:
GET /api/items?itemsPerPage=20with each item carrying those relations reproduces the issue.Possible Solution
Router::generate()'s underlying route collection doesn't change mid-request — routes are static. Caching the builtRouteCollectionon theRouterinstance fixes it:We validated this exact fix (via a userland decorator, since
Routerisfinaland can't be subclassed/patched directly — we wrap it and rebind the two consumers that resolve it: theUrlGeneratorInterfacealias, andIriConverter::class, which injects the concreteRouterdirectly rather than via the interface):toSymfonyRouteCollection()call count: ~65 → 1-2 per request.Additional Context
IriConverter(vendor/api-platform/laravel/Routing/IriConverter.php) is constructed inApiPlatformProvider::register()with the concreteRouterinstance injected directly (typed asSymfony\Component\Routing\RouterInterface), bypassing theApiPlatform\Metadata\UrlGeneratorInterfacebinding entirely — so a fix needs to land inRouteritself (orIriConverterneeds to stop depending on the concrete class) rather than just decorating the interface binding.SkolemIriConverter(vendor/api-platform/laravel/Routing/SkolemIriConverter.php) strictly type-hints the literal concreteRouterclass in its constructor, which is why a userland fix can't simply swap theRouter::classcontainer binding for a wrapper — any replacement must remaininstanceof Router, which isn't possible since the class isfinal. This is itself worth fixing upstream (either makeRouternon-final, or haveSkolemIriConverterdepend on an interface).