Joseph Astrahan Joseph Astrahan - 3 months ago 12
PHP Question

Symfony 3 Redirect All Routes To Current Locale Version

I am working on a symfony application where my goal is no matter what page the user is on it will navigate to the locale version of the page.

For example, if the user navigates to "/" the home page, it will redirect to "/en/"

If they are on "/admin" page it will redirect to "/en/admin", in such a way that the

_locale
property is set from the route.

Also it needs to determine the locale if they visit /admin from the users browser since no locale was determined so it knows which page to redirect to.

Currently my default controller looks like below since I'm testing. I'm using the dev mode & profiler to test that translations are working in correct.

enter image description here

<?php

namespace AppBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;

class DefaultController extends Controller
{
/**
* @Route("/", name="homepage")
* @Route("/{_locale}/", name="homepage_locale")
*/
public function indexAction(Request $request)
{
$translated = $this->get('translator')->trans('Symfony is great');

// replace this example code with whatever you need
return $this->render('default/index.html.twig', [
'base_dir' => realpath($this->container->getParameter('kernel.root_dir').'/..'),
'translated' => $translated
]);
}
}


This current method will keep the user at "/" if they navigate there, but I want to have it redirect to "/en/". This should work for other pages too, like /admin, or /somepath/pathagain/article1 (/en/admin , /en/somepath/pathagain/article1)

How would I do this?

References I've read that did not help:

Symfony2 Use default locale in routing (one URL for one language)

Symfony2 default locale in routing

::Update::

I have not solved my issue but I've come close as well as learned a few tricks to be more efficient.

DefaultController.php

<?php

namespace AppBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;

class DefaultController extends Controller
{

/**
* @Route("/", name="home", defaults={"_locale"="en"}, requirements={"_locale" = "%app.locales%"})
* @Route("/{_locale}/", name="home_locale", requirements={"_locale" = "%app.locales%"})
*/
public function indexAction(Request $request)
{
$translated = $this->get('translator')->trans('Symfony is great');

// replace this example code with whatever you need
return $this->render('default/index.html.twig', [
'base_dir' => realpath($this->container->getParameter('kernel.root_dir').'/..'),
'translated' => $translated
]);
}

/**
* @Route("/admin", name="admin", defaults={"_locale"="en"}, requirements={"_locale" = "%app.locales%"})
* @Route("/{_locale}/admin", name="admin_locale", requirements={"_locale" = "%app.locales%"})
*/
public function adminAction(Request $request)
{
$translated = $this->get('translator')->trans('Symfony is great');

// replace this example code with whatever you need
return $this->render('default/index.html.twig', [
'base_dir' => realpath($this->container->getParameter('kernel.root_dir').'/..'),
'translated' => $translated
]);
}
}
?>


Config.yml

imports:
- { resource: parameters.yml }
- { resource: security.yml }
- { resource: services.yml }

# Put parameters here that don't need to change on each machine where the app is deployed
# http://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters:
locale: en
app.locales: en|es|zh

framework:
#esi: ~
translator: { fallbacks: ["%locale%"] }
secret: "%secret%"
router:
resource: "%kernel.root_dir%/config/routing.yml"
strict_requirements: ~
form: ~
csrf_protection: ~
validation: { enable_annotations: true }
#serializer: { enable_annotations: true }
templating:
engines: ['twig']
#assets_version: SomeVersionScheme
default_locale: "%locale%"
trusted_hosts: ~
trusted_proxies: ~
session:
# handler_id set to null will use default session handler from php.ini
handler_id: ~
save_path: "%kernel.root_dir%/../var/sessions/%kernel.environment%"
fragments: ~
http_method_override: true
assets: ~

# Twig Configuration
twig:
debug: "%kernel.debug%"
strict_variables: "%kernel.debug%"

# Doctrine Configuration
doctrine:
dbal:
driver: pdo_mysql
host: "%database_host%"
port: "%database_port%"
dbname: "%database_name%"
user: "%database_user%"
password: "%database_password%"
charset: UTF8
# if using pdo_sqlite as your database driver:
# 1. add the path in parameters.yml
# e.g. database_path: "%kernel.root_dir%/data/data.db3"
# 2. Uncomment database_path in parameters.yml.dist
# 3. Uncomment next line:
# path: "%database_path%"

orm:
auto_generate_proxy_classes: "%kernel.debug%"
naming_strategy: doctrine.orm.naming_strategy.underscore
auto_mapping: true

# Swiftmailer Configuration
swiftmailer:
transport: "%mailer_transport%"
host: "%mailer_host%"
username: "%mailer_user%"
password: "%mailer_password%"
spool: { type: memory }


Notice under parameters the value
app.locales: en|es|zh
. This is now a value I can reference whenever I create my routes if I plan to support more locales in the future which I do. Those routes are english, spanish, chinese in that order for those curious. In the DefaultController in the annotations the
"%app.locales%"
is the part that references the config parameter.

The problem with my current method is going to /admin for example does not redirect the user to /{browsers locale}/admin, which would be the more elegant solution to keep everything organized... but at least the routes work. Still looking for better solution.

****Update****

I think I may have possibly found the answer here as the bottom answer given (Add locale and requirements to all routes - Symfony2), the answer by Athlan. Just not sure how to implement this in symfony 3 as his directions were not clear enough to me.

I think this article might help also (http://symfony.com/doc/current/components/event_dispatcher/introduction.html)

Answer

After 12 hours of looking into this I finally found an acceptable solution. Please post revised versions of this solution if you can make it more efficient.

Some things to note, my solution is particular to my need. What it does is force any URL to go to a localized version if it exists.

This requires some conventions to be followed when you create routes.

DefaultController.php

<?php

namespace AppBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;

class DefaultController extends Controller
{

    /**
     * @Route("/{_locale}/", name="home_locale", requirements={"_locale" = "%app.locales%"})
     */
    public function indexAction(Request $request)
    {
        $translated = $this->get('translator')->trans('Symfony is great');

        // replace this example code with whatever you need
        return $this->render('default/index.html.twig', [
            'base_dir' => realpath($this->container->getParameter('kernel.root_dir').'/..'),
            'translated' => $translated
        ]);
    }

    /**
     * @Route("/{_locale}/admin", name="admin_locale", requirements={"_locale" = "%app.locales%"})
     */
    public function adminAction(Request $request)
    {
        $translated = $this->get('translator')->trans('Symfony is great');

        // replace this example code with whatever you need
        return $this->render('default/index.html.twig', [
            'base_dir' => realpath($this->container->getParameter('kernel.root_dir').'/..'),
            'translated' => $translated
        ]);
    }
}
?>

Notice that both routes always start with "/{_locale}/". For this to work every route in your project needs to have this. You just put the real route name afterwards. For me I was okay with this scenario. You can modify my solution to fit your needs easily enough.

The first step is to create a listen on the httpKernal to intercept requests before they go to the routers to render them.

LocaleRewriteListener.php

<?php
//src/AppBundle/EventListener/LocaleRewriteListener.php
namespace AppBundle\EventListener;

use Symfony\Component\HttpFoundation\RedirectResponse;

use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Routing\RouteCollection;

class LocaleRewriteListener implements EventSubscriberInterface
{
    /**
     * @var Symfony\Component\Routing\RouterInterface
     */
    private $router;

    /**
    * @var routeCollection \Symfony\Component\Routing\RouteCollection
    */
    private $routeCollection;

    /**
     * @var string
     */
    private $defaultLocale;

    /**
     * @var array
     */
    private $supportedLocales;

    /**
     * @var string
     */
    private $localeRouteParam;

    public function __construct(RouterInterface $router, $defaultLocale = 'en', array $supportedLocales = array('en'), $localeRouteParam = '_locale')
    {
        $this->router = $router;
        $this->routeCollection = $router->getRouteCollection();
        $this->defaultLocale = $defaultLocale;
        $this->supportedLocales = $supportedLocales;
        $this->localeRouteParam = $localeRouteParam;
    }

    public function isLocaleSupported($locale) 
    {
        return in_array($locale, $this->supportedLocales);
    }

    public function onKernelRequest(GetResponseEvent $event)
    {
        //GOAL:
        // Redirect all incoming requests to their /locale/route equivlent as long as the route will exists when we do so.
        // Do nothing if it already has /locale/ in the route to prevent redirect loops

        $request = $event->getRequest();
        $path = $request->getPathInfo();

        $route_exists = false; //by default assume route does not exist.

        foreach($this->routeCollection as $routeObject){
            $routePath = $routeObject->getPath();
            if($routePath == "/{_locale}".$path){
                $route_exists = true;
                break;
            }
        }

        //If the route does indeed exist then lets redirect there.
        if($route_exists == true){
            //Get the locale from the users browser.
            $locale = $request->getPreferredLanguage();

            //If no locale from browser or locale not in list of known locales supported then set to defaultLocale set in config.yml
            if($locale==""  || $this->isLocaleSupported($locale)==false){
                $locale = $request->getDefaultLocale();
            }

            $event->setResponse(new RedirectResponse("/".$locale.$path));
        }

        //Otherwise do nothing and continue on~
    }

    public static function getSubscribedEvents()
    {
        return array(
            // must be registered before the default Locale listener
            KernelEvents::REQUEST => array(array('onKernelRequest', 17)),
        );
    }
}

Finally you set the services.yml to start the listener up.

Services.yml

# Learn more about services, parameters and containers at
# http://symfony.com/doc/current/book/service_container.html
parameters:
#    parameter_name: value

services:
#    service_name:
#        class: AppBundle\Directory\ClassName
#        arguments: ["@another_service_name", "plain_value", "%parameter_name%"]
     appBundle.eventListeners.localeRewriteListener:
          class: AppBundle\EventListener\LocaleRewriteListener
          arguments: ["@router", "%kernel.default_locale%", "%locale_supported%"]
          tags:
            - { name: kernel.event_subscriber }

Also in the config.yml you will want to add the following under parameters:

config.yml

parameters:
    locale: en
    app.locales: en|es|zh
    locale_supported: ['en','es','zh']

I wanted there to be only one place you define the locales but I wound up having to do 2...but at least they are in the same spot so easy to change.

app.locales is used in default controller (requirements={"_locale" = "%app.locales%"}) and locale_supported is used in the LocaleRewriteListener. If it detects a locale that is not in the list it will fallback to the default locale, which in this case is the value of locale:en.

app.locales is nice with the requirements command because it will cause a 404 for any locales that do not match.

If you are using forms and have a login you will need to do the following to your security.yml

Security.yml

# To get started with security, check out the documentation:
# http://symfony.com/doc/current/book/security.html
security:
    encoders:
        Symfony\Component\Security\Core\User\User:
            algorithm: bcrypt
            cost: 12
        AppBundle\Entity\User:
            algorithm: bcrypt
            cost: 12

    role_hierarchy:
        ROLE_ADMIN:       ROLE_USER
        ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]

    providers:
        # http://symfony.com/doc/current/book/security.html#where-do-users-come-from-user-providers
         database:
              entity: { class: AppBundle:User }
                #property: username
                # if you're using multiple entity managers
                # manager_name: customer

    firewalls:
        # disables authentication for assets and the profiler, adapt it according to your needs
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            pattern: ^/
            anonymous: true

            form_login:
                check_path: login_check
                login_path: login_route
                provider: database
                csrf_token_generator: security.csrf.token_manager

            remember_me:
                secret:   '%secret%'
                lifetime: 604800 # 1 week in seconds
                path:     /
                httponly: false
                #httponly false does make this vulnerable in XSS attack, but I will make sure that is not possible.
            logout:
                path:   /logout
                target: /

    access_control:
        # require ROLE_ADMIN for /admin*
        #- { path: ^/login, roles: ROLE_ADMIN }
        - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/(.*?)/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/, roles: ROLE_USER }

The important change to note here is that (.*?)/login will authenticate anonymously so your users can still login. This does mean that routes like..dogdoghere/login could trigger, but the requirements I will show you shortly on the login routes prevent this and will throw 404 errors. I like this solution with the (.*?) versus [a-z]{2} incase you wanted to use en_US type locales.

SecurityController.php

<?php
// src/AppBundle/Controller/SecurityController.php
namespace AppBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;

use Symfony\Component\HttpFoundation\Request;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;

class SecurityController extends Controller
{
    /**
     * @Route("{_locale}/login", name="login_route", defaults={"_locale"="en"}, requirements={"_locale" = "%app.locales%"})
     */
    public function loginAction(Request $request)
    {
        $authenticationUtils = $this->get('security.authentication_utils');

        // get the login error if there is one
        $error = $authenticationUtils->getLastAuthenticationError();

        // last username entered by the user
        $lastUsername = $authenticationUtils->getLastUsername();

        return $this->render(
            'security/login.html.twig',
            array(
                // last username entered by the user
                'last_username' => $lastUsername,
                'error'         => $error,
            )
        );
    }

    /**
     * @Route("/{_locale}/login_check", name="login_check", defaults={"_locale"="en"}, requirements={"_locale" = "%app.locales%"})
     */
    public function loginCheckAction()
    {
        // this controller will not be executed,
        // as the route is handled by the Security system
    }

    /**
    * @Route("/logout", name="logout")
    */
    public function logoutAction()
    {
    }
}
?>

Note that even these paths use {_locale} in front. I like this however so I can give custom logins for different locales. Just keep that in mind. The only route that does not need the locale is logout which works just fine since its really only an intercept route for the security system. Also notice it uses the requirements which is set from the config.yml, so you only have to edit it in one place for all the routes across your projects.

Hope this helps someone trying to do what I was doing!

NOTE:: To test this easily I use 'Quick Language Switcher' extension for Google Chrome, which changes the accept-language header on all requests.