How to access the mount point without a slash in Silex mounted routes

Submitted by Frederic Marand on

The problem: routing /blogs, not just /blogs/ in Silex

Route mounting in the Silex PHP framework allows conveniently grouping controllers per feature in separate files, then using mounting them on some prefix path like $app->mount('/blogs'). This will prepend the prefix "/blogs" to the paths defined in the the feature controller, effectively delegating to it all the /blogs/* routes. However, as the Silex documentation claims:

When mounting a route collection under /blog, it is not possible to define a route for the /blog URL. The shortest possible URL is /blog/.

This means handling the route mount point has to be done by a route outside the mounted feature, which makes it slightly less clean, as you have to do something like:

<?php
$blog
= require_once __DIR__ . '/controllers-blog.php';
// This will handle /blogs/ and below, but not /blogs
$app->mount('/blogs', $blog);
// So we have to use a non-mounted route from a sub-request to avoid a redirect().
$app->get('/blogs', function () use($app) {
 
// forward to /blogs/
 
$subRequest = Request::create('/blogs/' , 'GET');
  return
$app->handle($subRequest , HttpKernelInterface::SUB_REQUEST);
}
?>

But is there really no workaround for this limitation ? Sure there is!

The solution

Turns out this limitation is not completely real, thanks to the way the UrlMatcher from the Symfony Routing component works. To use the canonical example from the Silex documentation, let us define routes

<?php
// In file controllers-blog.php
$blog = $app ['controllers_factory'];
$blog->get('/', BlogController::class . 'indexAction')
  ->
bind('blog_list');
$blog->get('/{id}', BlogController::class . 'showAction')
  ->
assert('id', '\d+')
  ->
value('id', 1)
  ->
bind('blog_show');
// ...
return $blog;

// In file controllers.php
$blog = require_once __DIR__ . '/controllers-blog.php';
$app->mount('/blogs', $blog);
?>

At this point, accessing /blogs will actually work, but serving blog entry 1 using the $blogController->showAction(1), instead of using $blogController->indexAction() thanks to the default value() provided by the blog_show. Not what we are looking for, but notice how we are already serving /blogs from a route defined below the mount point, within the blogs controllers list.

Since multiple routes can handle the same path and will resolve in their order of appearance in the source file, all it takes is adding another more specific route for the same path, not conflicting with the blog_show route, in this case the blog_mount route:

<?php
// In file controllers-blog.php
const MAGIC = 'xyzzy';
$blog = $app ['controllers_factory'];
$blog->get('/', BlogController::class . 'indexAction')
  ->
bind('blog_list');
$blog->get('/{id}', BlogController::class . 'indexAction')
  ->
assert('id', MAGIC)
  ->
value(MAGIC)
  ->
bind('blog_mount');
$blog->get('/{id}', BlogController::class . 'showAction')
  ->
assert('id', '\d+')
  ->
value('id', 1)
  ->
bind('blog_show');
// ...
?>

Now when a GET request comes in for /blogs, it matches either blog_mount and blog_show. But since blog_mount is listed before blog_show, it will match first using the default "xyzzy" argument, satisfying its assert() requirement, and pass the request to the indexAction controller.

That'all it takes.

Why it works

To understand why things work that way, look at:

  • vendor/symfony/routing/Matcher/UrlMatcher::matchCollection() : the call to $route->compile() is where the magic lies, as it builds a match pattern which will handle the mount point itself thanks for the default value provided in the value() call.
  • vendor/symfony/routing/RouteCompiler::compile()<code> actually performs this in its <code>compilePattern() method.