Decorate the Symfony router to add a trailing slash to all URLs
Tags: symfony, routing, php, webdev
Published: 2024-07-05 13:21:43

I recently noticed an issue between the links that Symfony generated for [Password Angel](https://passwordangel.co) and the actual links that are in use.  When Symfony builds the URL there are no trailing slashes i.e. `/terms`, however, as [Password Angel](https://passwordangel.co) is hosted in an S3 bucket as a static site a trailing slash is part of the live URL i.e. `/terms/`.  This causes 2 problems:-

- Unnecessary redirections - All links in the page will refer to the link version without the trailing slash and then the user will need to be redirected to the version with the trailing slash.
- The canonical URLs are invalid - As I'm using Symfony to generate the canonical URL for each page, it generated the link version without the trailing slash.  This may cause SEO issues as search engines will 

  - visit `/terms`
  - be redirected to `/terms/`
  - be informed the original page is at `/terms`
  - ... go to step 1 - infinite loop ...

## Solution - Decorate the Symfony Router
To resolve this I created a decorator for the Symfony default router and have overridden the `generate` method to add a slash to the end of the URL.  It also checks for the presence of `?` which would indicate there are query string parameters and in this situation, I am inserting the `/` before the `?` as we want `/terms/?utm_campaign=...` and not `/terms?utm_campaign=.../`.

```php
<?php

declare(strict_types=1);

namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\HttpKernel\CacheWarmer\WarmableInterface;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\Routing\Router;
use Symfony\Component\Routing\RouterInterface;

#[AsDecorator('router.default')]
class TrailingSlashUrlGenerator implements RouterInterface, WarmableInterface
{
    public function __construct(
        private readonly Router $urlGenerator,
    ) {}

    public function generate($name, $parameters = [], $referenceType = self::ABSOLUTE_PATH): string
    {
        // Original URL
        $url = $this->urlGenerator->generate($name, $parameters, $referenceType);

        // Add the slash before any query string parameters
        $pos = strpos($url, '?');
        if ($pos !== false) {
            $parts = explode('?', $url, 2);
            if (str_ends_with($parts[0], '/') === false) {
                $parts[0] .= '/';
                return implode('?', $parts);
            }
        }

        // Add the slash at the end of the URL
        if (str_ends_with($url, '/') === false) {
            $url .= '/';
        }

        return $url;
    }

    public function match(string $pathinfo): array
    {
        return $this->urlGenerator->match($pathinfo);
    }

    public function getRouteCollection(): RouteCollection
    {
        return $this->urlGenerator->getRouteCollection();
    }

    public function setContext(RequestContext $context): void
    {
        $this->urlGenerator->setContext($context);
    }

    public function getContext(): RequestContext
    {
        return $this->urlGenerator->getContext();
    }

    public function warmUp(string $cacheDir, ?string $buildDir = null): array
    {
        return [];
    }
}
```

Note: To host [Password Angel](https://passwordangel.co) as a static site on S3, I have written a Symfony command to generate static versions of all the pages (all 4 of them) and these are uploaded to S3.  Let me know if you're interested and I'll post up how the Symfony command works.

----

Originally published at [https://chrisshennan.com/blog/decorate-the-symfony-router-to-add-a-trailing-slash-to-all-urls](https://chrisshennan.com/blog/decorate-the-symfony-router-to-add-a-trailing-slash-to-all-urls)