From d000a6b00e63ef4cc1c1417ab9a402e186ddbaaa Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 22 Jun 2026 12:06:45 +0200 Subject: [PATCH] perf(laravel): memoize the Symfony RouteCollection in Router Router::getRouteCollection() rebuilt the entire Symfony RouteCollection on every call via Illuminate\Routing\AbstractRouteCollection::toSymfonyRouteCollection(), converting the whole Laravel route table each time. generate() is called once per IRI during normalization, so a single response with several relations triggered the full conversion dozens of times (~65 for ~8 relations in profiling). Routes are static within a request and Router is a per-request singleton, so the built collection is cached on the instance. Also type SkolemIriConverter's constructor against Symfony\Component\Routing\RouterInterface instead of the final concrete Router, so consumers are no longer pinned to the concrete class. Closes #8337 --- src/Laravel/Routing/Router.php | 7 ++- src/Laravel/Routing/SkolemIriConverter.php | 3 +- src/Laravel/Tests/Unit/Routing/RouterTest.php | 44 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 src/Laravel/Tests/Unit/Routing/RouterTest.php diff --git a/src/Laravel/Routing/Router.php b/src/Laravel/Routing/Router.php index e57e59bd0ba..76da146cb36 100644 --- a/src/Laravel/Routing/Router.php +++ b/src/Laravel/Routing/Router.php @@ -37,6 +37,7 @@ final class Router implements RouterInterface, UrlGeneratorInterface ]; private RequestContext $context; + private ?RouteCollection $routeCollection = null; public function __construct(private readonly BaseRouter $router, private readonly int $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH) { @@ -63,10 +64,14 @@ public function getContext(): RequestContext */ public function getRouteCollection(): RouteCollection { + if (null !== $this->routeCollection) { + return $this->routeCollection; + } + /** @var \Illuminate\Routing\RouteCollection $routes */ $routes = $this->router->getRoutes(); - return $routes->toSymfonyRouteCollection(); + return $this->routeCollection = $routes->toSymfonyRouteCollection(); } /** diff --git a/src/Laravel/Routing/SkolemIriConverter.php b/src/Laravel/Routing/SkolemIriConverter.php index df0f41d61dd..e7b7daf0491 100644 --- a/src/Laravel/Routing/SkolemIriConverter.php +++ b/src/Laravel/Routing/SkolemIriConverter.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\UrlGeneratorInterface; +use Symfony\Component\Routing\RouterInterface; /** * {@inheritdoc} @@ -37,7 +38,7 @@ final class SkolemIriConverter implements IriConverterInterface */ private array $classHashMap = []; - public function __construct(private readonly Router $router) + public function __construct(private readonly RouterInterface $router) { $this->objectHashMap = new \SplObjectStorage(); } diff --git a/src/Laravel/Tests/Unit/Routing/RouterTest.php b/src/Laravel/Tests/Unit/Routing/RouterTest.php new file mode 100644 index 00000000000..d8d7e84164b --- /dev/null +++ b/src/Laravel/Tests/Unit/Routing/RouterTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Tests\Unit\Routing; + +use ApiPlatform\Laravel\Routing\Router; +use Illuminate\Routing\RouteCollection as IlluminateRouteCollection; +use Illuminate\Routing\Router as BaseRouter; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Routing\RouteCollection; + +class RouterTest extends TestCase +{ + public function testGetRouteCollectionIsMemoizedAcrossCalls(): void + { + $symfonyRoutes = new RouteCollection(); + + $illuminateRoutes = $this->createMock(IlluminateRouteCollection::class); + $illuminateRoutes->expects($this->once()) + ->method('toSymfonyRouteCollection') + ->willReturn($symfonyRoutes); + + $baseRouter = $this->createStub(BaseRouter::class); + $baseRouter->method('getRoutes')->willReturn($illuminateRoutes); + + $router = new Router($baseRouter); + + $first = $router->getRouteCollection(); + $second = $router->getRouteCollection(); + + $this->assertSame($symfonyRoutes, $first); + $this->assertSame($first, $second); + } +}