Sponsored By: Password Angel - Share passwords, API keys, credentials and more with secure, single-use links.

Decorate the Symfony router to add a trailing slash to all URLs

I recently noticed an issue between the links that Symfony generated for PasswordAngel and the actual links that are in use. When Symfony builds the URL there are no trailing slashes i.e. /terms, however, as PasswordAngel 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=.../.



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;

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

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

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

Note: To host PasswordAngel 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.

Enjoyed this article?

Thank you for reading this article! If you found the information valuable and enjoyed your time here, consider supporting my work by buying me a coffee. Your contribution helps fuel more content like this. Cheers to shared knowledge and caffeine-fueled inspiration!

Buy me a coffee

Originally published at

Subscribe to my newsletter...

... and receive the musings of an aspiring #indiehacker directly to your inbox once a month.

These musings will encompass a range of subjects such as Web Development, DevOps, Startups, Bootstrapping, #buildinpublic, SEO, personal opinions, and experiences.

I won't send you spam and you can unsubscribe at any time.