Symfony routing tricks (part 2)
In part 1 we explored some advanced Symfony routing techniques:
- Using an event listener to override the router;
- Creating routes programatically;
- Adding expression language requirements;
- Combining an event listener, programmatic route creation, and expression language requirements together to make an API that routes across different versions based on the
Accept
header.
In this post we’ll look at another technique from a real-life project: writing a custom router to handle some unusual requirements.
New dashboard, new router
Our friends at EmailOctopus are constantly improving their product, and in 2019 they launched a brand new version of their customer dashboard:
We worked on this new dashboard together in secret before unveiling it to their customers. It was a major project that required significant changes to the codebase, which we aimed to make gradually through a series of small pull requests.
We wanted users to be able to “opt-in” and try out the V2 dashboard while it was still being worked on. On the other hand, we wanted the existing V1 experience to continue uninterrupted.
We came up with these requirements:
- A user can opt-in and opt-out of the new dashboard at any time.
- When opted-in, the user should see the V2 dashboard only.
- When opted-out, the user should see the V1 dashboard only.
- A V1 and V2 route can both have the same URL. For example,
/campaigns
would invoke the V2 campaigns controller while opted-in, and the V1 campaigns controller when opted-out. - A common collection of routes should be available for both versions, e.g.
/
for the homepage or/legal/privacy
for the privacy policy. - Above all, do not break the existing dashboard!
To fulfil these requirements, we decided to write a custom implementation of Symfony’s RouterInterface
.
Designing the router
After talking through various options, we decided to create two separate routers for the V1 and V2 routes, then another router that aggregates both of them:
Let’s build our own version in this post, using a typical Symfony application as a base.
Split existing routes
To start, we’ll split the existing routing files to match this portion of the diagram:
Assuming a Symfony flex structure, convert routes.yaml
into two files:
protected function configureRoutes(RouteCollectionBuilder $routes)
{
$confDir = $this->getProjectDir().'/config';
$routes->import($confDir.'/{routes}/*'.self::CONFIG_EXTS, '/', 'glob');
$routes->import($confDir.'/{routes}/'.$this->environment.'/**/*'.self::CONFIG_EXTS, '/', 'glob');
- $routes->import($confDir.'/{routes}'.self::CONFIG_EXTS, '/', 'glob');
+ $routes->import($confDir.'/routes_shared.yaml');
+ $routes->import($confDir.'/routes_v1.yaml');
}
# routes_shared.yaml
sales:
resource: ../src/Controller/Sales/
type: annotation
legal:
resource: ../src/Controller/Legal/
type: annotation
# routes_v1.yaml
dashboard:
resource: ../src/Controller/Dashboard/
type: annotation
The router
service in the container now represents ‘V1 router’ in the diagram.
Fortunately, EmailOctopus had controllers grouped into separate directories, which made this segmentation quite easy.
Create V2 routes
To create ‘V2 router’ in the diagram, we’ll copy ‘V1 router’ and tweak it a bit:
Add a process()
method to the Kernel
, registering it as a compiler pass:
+ use Symfony\Component\DependencyInjection\ContainerBuilder;
+ use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
class Kernel extends BaseKernel implements CompilerPassInterface
{
+ public function process(ContainerBuilder $container)
+ {
+ }
}
In it we’ll clone the existing router
service, and tweak the definition to coexist peacefully with the other router (change the name of the cached matcher and generator).
<?php
public function process(ContainerBuilder $container)
{
$v1Router = $container->findDefinition('router');
$v2Router = clone $v1Router;
$v2Router->setArgument(1, 'kernel::loadV2Routes');
$v2Router->setArgument(2, array_merge(
$v2Router->getArgument(2),
[
'generator_cache_class' => '%router.cache_class_prefix%V2UrlGenerator',
'matcher_cache_class' => '%router.cache_class_prefix%V2UrlMatcher',
]
));
}
We’re not doing anything with this service definition at the moment, but we’ll use it shortly.
Create Kernel::loadV2Routes()
:
<?php
public function loadV2Routes(LoaderInterface $loader)
{
$routes = new RouteCollectionBuilder($loader);
$confDir = $this->getProjectDir() . '/config';
$routes->import($confDir.'/{routes}/*'.self::CONFIG_EXTS, '/', 'glob');
$routes->import($confDir.'/{routes}/'.$this->environment.'/**/*'.self::CONFIG_EXTS, '/', 'glob');
$routes->import($confDir.'/routes_shared.yaml');
$routes->import($confDir.'/routes_v2.yaml');
return $routes->build();
}
Note that this is not the same as the existing configureRoutes()
method: our method is derived from MicroKernelTrait::loadRoutes() instead.
Finally, add routes_v2.yaml
:
# routes_v2.yaml
dashboard:
resource: ../src/Controller/DashboardV2/
type: annotation
We’ve created a new router service definition, but not doing anything with it yet. Let’s combine the two routers together.
Combine both routers
Create an AggregateRouter
class, which implements RouterInterface
by delegating to one of the two routers passed to it:
<?php
namespace App\Routing;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\Exception\RouteNotFoundException;
use Symfony\Component\HttpFoundation\Request;
class AggregateRouter implements RouterInterface
{
private $v1Router;
private $v2Router;
public function __construct(RouterInterface $v1Router, RouterInterface $v2Router)
{
$this->v1Router = $v1Router;
$this->v2Router = $v2Router;
}
public function getRouteCollection()
{
$routes = $this->v1Router->getRouteCollection();
$routes->addCollection($this->v2Router->getRouteCollection());
return $routes;
}
public function setContext(RequestContext $context)
{
return $this->v1Router->setContext($context);
}
public function getContext()
{
return $this->v1Router->getContext();
}
public function match($pathinfo)
{
$optedIn = false;
if ($optedIn) {
return $this->v2Router->match($pathInfo);
}
return $this->v1Router->match($pathinfo);
}
public function generate($name, $parameters = [], $referenceType = self::ABSOLUTE_PATH)
{
try {
return $this->v1Router->generate($name, $parameters, $referenceType);
} catch (RouteNotFoundException $e) {
return $this->v2Router->generate($name, $parameters, $referenceType);
}
}
}
It delegates all the decisions to the V1 router for now, since $optedIn
is hardcoded to false
.
Update Kernel::process()
to register it as the new router
service:
public function process(ContainerBuilder $container)
{
$v1Router = $container->findDefinition('router');
$v2Router = clone $v1Router;
$v2Router->setArgument(1, 'kernel::loadV2Routes');
$v2Router->setArgument(2, array_merge(
$v2Router->getArgument(2),
[
'generator_cache_class' => '%router.cache_class_prefix%V2UrlGenerator',
'matcher_cache_class' => '%router.cache_class_prefix%V2UrlMatcher',
]
));
+ $aggregateRouter = (new Definition(AggregateRouter::class))
+ ->setArguments([
+ $v1Router,
+ $v2Router,
+ ])
+ ->setPublic(true);
+
+ $container->setDefinition('router', $aggregateRouter);
}
After this change, the router
service in the container will be an instance of AggregateRouter
.
Since it only uses the V1 router at the moment, our application still behaves exactly the same.
Actually use both routers
The application routing now looks a bit like this:
How can we use RouterInterface::match()
to make the V1/V2 decision when we only have $pathinfo
available?
As our requirements state, both V1 and V2 need to available at /campaigns
.
Fortunately there’s another option - Symfony\Component\Routing\Matcher\RequestMatcherInterface::matchRequest() expects a Symfony\Component\HttpFoundation\Request
object instead of a $pathinfo
string.
If a router implements this interface, Symfony will call matchRequest()
instead of match()
to route the request.
Update AggregateRouter
to implement it:
+ use Symfony\Component\Routing\Matcher\RequestMatcherInterface;
+ class AggregateRouter implements RouterInterface, RequestMatcherInterface
- class AggregateRouter implements RouterInterface
public function match($pathinfo)
{
+ throw new RouteNotFoundException(
+ __CLASS__ . ' relies on ' . RequestMatcherInterface::class
+ );
+ }
+
+ public function matchRequest(Request $request)
+ {
$optedIn = false;
if ($optedIn) {
This gives us access to the request object to make the V1/V2 decision.
We’ve also updated match()
to throw an error to stop the router being used incorrectly.
With access to the request, we can easily check opt-in status using a cookie:
public function matchRequest(Request $request)
{
- $optedIn = false;
+ $optedIn = $request->cookies->get('v2') === 'y';
if ($optedIn) {
return $this->v2Router->match($pathInfo);
}
return $this->v1Router->match($pathinfo);
}
Perfect! A request to /campaigns
with the v2
cookie set to y
will go to the V2 endpoint, and the V1 endpoint if not.
As routes_shared.yaml
is loaded by both V1 and V2 routers, we’ll always be able to access common pages like /legal/privacy
regardless of cookie status.
Finishing touches
All that remains is to set the cookie for selected users. For EmailOctopus, we added messages to both dashboard versions:
Each would link to a controller that toggled the status of the cookie.
<?php
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
class DashboardPreferenceController
{
/**
* @Route("/_dashboard_preference")
*
* Opt the user in or out of the V2 dashboard,
* then redirect them to the dashboard homepage.
*/
public function togglePreference(Request $request)
{
$currentlyOptedIn = $request->cookies->get('v2') === 'y';
// Note that /dashboard is available in both V1 and V2
$response = new RedirectResponse('/dashboard');
$response->headers->setCookie(new Cookie('v2', $currentlyOptedIn ? '' : 'y'));
return $response;
}
}
Going back to normal
When the V2 migration was fully completed, we simply reversed the process to go back to using a regular Symfony router, then deleted all the V1 routes.
Conclusion
This concludes our 2-part series on advanced Symfony routing techniques, I hope you learned something new!
Do you need help with routing in your application? Send us an email, we’d be delighted to help you.