Skip to content

Laravel: Router::generate() rebuilds the entire Symfony RouteCollection on every call — no memoization #8337

@PicassoHouessou

Description

@PicassoHouessou

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

  1. 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).
  2. GET the collection endpoint for that resource with several items, each carrying those relations.
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions