We saw in a previous article how to install Symfony 6 +
Sonata Admin 5 with user and media management.
We will
continue this installation with the configuration of languages and
time zones.
We will install intlBundle which allows to
manage localized display.
php8.1 composer.phar require sonata-project/intl-bundle
We take this opportunity to add a timezone and locale field to the
user.
To do this, we will extend userBundle and add a field in
the admin.
We extend user by moving the user entity to:
src/Application/Sonata/UserBundle/Entity/User.php
And we add to
it a timezone field and a locale field.
<?php
namespace App\Application\Sonata\UserBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Sonata\UserBundle\Entity\BaseUser;
/**
* @ORM\Entity
* @ORM\Table(name="user__user")
*/
class User extends BaseUser
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
protected $id;
/**
* @var string|null
*
* @ORM\Column(name="timezone", type="string", length=64, nullable=true)
*/
protected $timezone;
/**
* @var string|null
*
* @ORM\Column(name="locale", type="string", length=8, nullable=true)
*/
protected $locale;
public function getTimezone(){
return $this->timezone;
}
public function setTimezone($timezone){
$this->timezone=$timezone;
return $this;
}
public function getLocale(){
return $this->locale;
}
public function setLocale($locale){
$this->locale=$locale;
return $this;
}
}
We duplicate Sonata's admin and place it in
src/Application/Sonata/UserBundle/Admin/Model/UserAdmin.php
We
add our timezone field to it.
<?php
declare(strict_types=1);
/*
* This file is part of the Sonata Project package.
*
* (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Application\Sonata\UserBundle\Admin\Model;
use Sonata\AdminBundle\Admin\AbstractAdmin;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\FieldDescription\FieldDescriptionInterface;
use Sonata\AdminBundle\Form\FormMapper;
use Sonata\AdminBundle\Show\ShowMapper;
use Sonata\UserBundle\Form\Type\RolesMatrixType;
use Sonata\UserBundle\Model\UserManagerInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TimezoneType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
/**
* @phpstan-extends AbstractAdmin<\Sonata\UserBundle\Model\UserInterface>
*/
class UserAdmin extends AbstractAdmin
{
protected UserManagerInterface $userManager;
public function __construct(UserManagerInterface $userManager)
{
parent::__construct();
$this->userManager = $userManager;
}
protected function preUpdate(object $object): void
{
$this->userManager->updatePassword($object);
}
protected function configureFormOptions(array &$formOptions): void
{
$formOptions['validation_groups'] = ['Default'];
if (!$this->hasSubject() || null === $this->getSubject()->getId()) {
$formOptions['validation_groups'][] = 'Registration';
} else {
$formOptions['validation_groups'][] = 'Profile';
}
}
protected function configureListFields(ListMapper $list): void
{
$list
->addIdentifier('username')
->add('email')
->add('enabled', null, ['editable' => true])
->add('createdAt');
if ($this->isGranted('ROLE_ALLOWED_TO_SWITCH')) {
$list
->add('impersonating', FieldDescriptionInterface::TYPE_STRING, [
'virtual_field' => true,
'template' => '@SonataUser/Admin/Field/impersonating.html.twig',
]);
}
}
protected function configureDatagridFilters(DatagridMapper $filter): void
{
$filter
->add('id')
->add('username')
->add('email');
}
protected function configureShowFields(ShowMapper $show): void
{
$show
->add('username')
->add('email');
}
protected function configureFormFields(FormMapper $form): void
{
$form
->with('general', ['class' => 'col-md-4'])
->add('username')
->add('email')
->add('locale', LocaleType::class, ['required' => false])
->add('timezone', TimezoneType::class, ['required' => false])
->add('plainPassword', TextType::class, [
'required' => (!$this->hasSubject() || null === $this->getSubject()->getId()),
])
->add('enabled', null)
->end()
->with('roles', ['class' => 'col-md-8'])
->add('realRoles', RolesMatrixType::class, [
'label' => false,
'multiple' => true,
'required' => false,
])
->end();
}
protected function configureExportFields(): array
{
// Avoid sensitive properties to be exported.
return array_filter(parent::configureExportFields(), static function (string $v): bool {
return !\in_array($v, ['password', 'salt'], true);
});
}
}
We change the references of our entity and our admin in sonata_user.yml
sonata_user:
class:
user: App\Application\Sonata\UserBundle\Entity\User
admin:
user:
class: App\Application\Sonata\UserBundle\Admin\Model\UserAdmin
resetting:
email:
address: "test@test.com"
sender_name: Backoffice
security_acl: true
manager_type: orm # can be orm or mongodb
and we register our extension in doctrine.yml
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '13'
orm:
auto_generate_proxy_classes: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
App:
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
App\Application\Sonata\MediaBundle:
is_bundle: false
dir: '%kernel.project_dir%/src/Application/Sonata/MediaBundle/Entity'
prefix: 'App\Application\Sonata\MediaBundle\Entity'
alias: App\Application\Sonata\MediaBundle
App\Application\Sonata\UserBundle:
is_bundle: false
dir: '%kernel.project_dir%/src/Application/Sonata/UserBundle/Entity'
prefix: 'App\Application\Sonata\UserBundle\Entity'
alias: App
We update our schema and we test.
php8.1 bin/console doctrine:schema:update --force
Our new field is well taken into account.

We set the default locale to FR
#config/packages/translation.yaml
framework:
default_locale: fr
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
- fr
We configure sonata_intl
sonata_intl:
timezone:
default: Europe/Paris
locales:
fr: Europe/Paris
en_UK: Europe/London
detectors:
- sonata.intl.timezone_detector.user
- sonata.intl.timezone_detector.locale
We now wish that the timezone and the default language settings of
our user are taken into account.
To do this, we will retrieve
his/her settings at the time of login and put them in
session.
Then, we will retrieve this information at each page
load and overload the interface with these settings.
First step, we get the info at login. To do this, we will specify
the service to use during the handler_success in security.yaml.
We set success_handler: login_success_handler
security:
enable_authenticator_manager: true
password_hashers:
Sonata\UserBundle\Model\UserInterface:
algorithm: auto
providers:
sonata_user_bundle:
id: sonata.user.security.user_provider
access_decision_manager:
strategy: unanimous
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
admin:
lazy: true
pattern: /admin(.*)
provider: sonata_user_bundle
context: user
switch_user: true
form_login:
login_path: sonata_user_admin_security_login
check_path: sonata_user_admin_security_check
default_target_path: sonata_admin_dashboard
success_handler: login_success_handler
logout:
path: sonata_user_admin_security_logout
target: sonata_user_admin_security_login
remember_me:
#secret: "%env(APP_SECRET)%"
secret: "123456"
lifetime: 2629746
path: /admin
access_control:
- { path: ^/admin/login$, role: PUBLIC_ACCESS }
- { path: ^/admin/logout$, role: PUBLIC_ACCESS }
- { path: ^/admin/login_check$, role: PUBLIC_ACCESS }
- { path: ^/admin/request$, role: PUBLIC_ACCESS }
- { path: ^/admin/check-email$, role: PUBLIC_ACCESS }
- { path: ^/admin/reset/.*$, role: PUBLIC_ACCESS }
- { path: ^/admin/, role: ROLE_ADMIN }
role_hierarchy:
ROLE_ADMIN:
- ROLE_USER
- ROLE_SONATA_ADMIN
- ROLE_SONATA_USER_ADMIN_USER_VIEW
ROLE_SUPER_ADMIN:
- ROLE_ADMIN
- ROLE_ALLOWED_TO_SWITCH
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon
Then we add our service in service.yaml
services:
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
login_success_handler:
parent: security.authentication.success_handler
class: App\EventListener\LoginSuccessHandler
And we create our Handler in src/EventListener/LoginSuccessHandler.php
<?php
namespace App\EventListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\HttpUtils;
use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationSuccessHandler;
class LoginSuccessHandler extends DefaultAuthenticationSuccessHandler
{
public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
{
$locale = $token->getUser()->getLocale();
$request->getSession()->set('_locale', $locale);
$request->setLocale($locale);
$timezone = $token->getUser()->getTimezone();
$request->getSession()->set('_timezone', $timezone);
return $this->httpUtils->createRedirectResponse($request, $this->determineTargetUrl($request));
}
}
At this stage, we have the preferences in session at the time of
login. We now add 2 Listeners. LocaleListener and
TimezoneListener.
We add them to the services.
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
login_success_handler:
parent: security.authentication.success_handler
class: App\EventListener\LoginSuccessHandler
App\EventListener\LocaleListener:
class: App\EventListener\LocaleListener
arguments: [ "%kernel.default_locale%"]
tags:
- { name: kernel.event_subscriber }
App\EventListener\TimeZoneListener:
class: App\EventListener\TimeZoneListener
arguments: ["@twig"]
tags:
- { name: kernel.event_subscriber }
And we create our listeners.
<?php
#src/EventListener/LocaleListener.php
namespace App\EventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class LocaleListener implements EventSubscriberInterface
{
private $defaultLocale;
public function __construct($defaultLocale = 'fr')
{
$this->defaultLocale = $defaultLocale;
}
public function onKernelRequest(RequestEvent $event)
{
$request = $event->getRequest();
if (!$request->hasPreviousSession()) {
return;
}
$request->setLocale($request->getSession()->get('_locale', $this->defaultLocale));
}
public static function getSubscribedEvents()
{
return array(
KernelEvents::REQUEST => array(array('onKernelRequest', 17)),
);
}
}
<?php
#src/EventListener/TimeZoneListener.php
namespace App\EventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Twig\Environment;
use Twig\Extension\CoreExtension;
/**
* Class TimeZoneListener
*
* @package App\EventListener
*/
class TimeZoneListener implements EventSubscriberInterface
{
/** @var twig */
private $twig;
public function __construct( $twig)
{
$this->twig = $twig;
}
public function onKernelRequest( RequestEvent $request)
{
$request = $request->getRequest();
$timezone = $request->getSession()->get('_timezone', "Europe/Paris");
$core = $this->twig->getExtension('Twig\Extension\CoreExtension');
$core->setTimezone($timezone);
return $core;
}
public static function getSubscribedEvents()
{
return array(
KernelEvents::REQUEST => array(array('onKernelRequest', 18)),
);
}
}
All we have left is to check that it has been taken into
account.
We first set our preferences in English locale and
timezone to the other side of the world.
We don't have a Vietnamese translation, so we fallback to EN. We do
have the list of translated locales, however.
The interface is in English and the dates are translated to
Vietnamese, and the time matches the HCMC timezone.
We now
switch back to FR and Europe/Paris.
Our interface is in French and the listboxes as well.
And our dates are written in the FR format and the time is set to Paris. 11:24 in Paris vs 5:24 p.m. in Vietnam.