Skip to content

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

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions