Permalink

8

User-Specific Timezones With Symfony2 and Twig Extensions

It’s considered a best-practice to store all your times in the same timezone. Usually this timezone is UTC. Whenever a user enters a time it should be converted from their local timezone to UTC before being persisted. Whenever you want to display a timezone to a user it can be converted from UTC to whichever timezone they prefer. This normalizes the data in your database to a common timezone which allows for simpler querying and data aggregation. It also gives you the flexibility of having simple user-specific timezones that can be changed.

We can use Symfony’s events and dependency injection to make this conversion as seamless as possible.

Setting the timezone in the session

All of my users timezones are stored in the database. I would like my application to set this timezone on the users session when they first log in. To do this I created an event listener and bound it to the kernel.request event.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
<?php
 
namespace SOTB\CoreBundle\EventListener;
 
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Core\SecurityContext;
use Symfony\Component\HttpFoundation\Session\Session;
 
/**
* @author Matt Drollette <[email protected]>
*/
class SecurityListener
{
protected $security;
protected $session;
 
/**
* Constructs a new instance of SecurityListener.
*
* @param SecurityContext $security The security context
* @param Session $session The session
*/
public function __construct(SecurityContext $security, Session $session)
{
$this->security = $security;
$this->session = $session;
}
 
/**
* Invoked after a successful login.
*
* @param InteractiveLoginEvent $event The event
*/
public function onSecurityInteractiveLogin(InteractiveLoginEvent $event)
{
$timezone = $this->security->getToken()->getUser()->getTimezone();
if (empty($timezone)) {
$timezone = 'UTC';
}
$this->session->set('timezone', $timezone);
}
}
view raw SecurityListener.php hosted with ❤ by GitHub

1 2 3 4 5
<service id="sotb_core.listener.login" class="SOTB\CoreBundle\EventListener\SecurityListener">
<tag name="kernel.event_listener" event="security.interactive_login" method="onSecurityInteractiveLogin"/>
<argument type="service" id="security.context"/>
<argument type="service" id="session"/>
</service>
view raw services.xml hosted with ❤ by GitHub

In the event listener I am injecting the security context and the session as dependencies. When the user logs in the SecurityListener object will be instantiated and the onSecurityInteractiveLogin method will be called. This will get the users timezone and set it to a session variable.

Converting timezones in forms

We now need to handle the transformation of the dates from the normalized database times into the users timezone and vice-versa when editing the objects via forms. Symfony makes this easy by providing a user_timezone and data_timezone option on the date form field type.

When I create the form in the controller I pass in the user_timezone option from the value stored in the session.

$this->container->get('form.factory')->create('sotb_invoice_type', $invoice, 
    array('user_timezone' => $this->container->get('session')->get('timezone'))
);

Symfony will handle the data transformation automatically after you pass the timezones to the date/time fields.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
<?php
 
namespace SOTB\InvoiceBundle\Form\Type;
 
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
 
/**
* Invoice form type.
*
* @author Matt Drollette <[email protected]>
*/
class InvoiceType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('dateOfIssue', 'date', array(
'data_timezone' => 'UTC',
'user_timezone' => $options['user_timezone']
))
->add('terms', 'textarea', array(
'required' => false
))
->add('visibleNotes', 'textarea', array(
'required' => false
))
;
}
 
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver
->setDefaults(array(
'data_class' => 'SOTB\CoreBundle\Document\Invoice',
'user_timezone' => 'UTC'
));
}
 
public function getName()
{
return 'sotb_invoice_type';
}
}
view raw InvoiceType.php hosted with ❤ by GitHub

Displaying times in the users timezone

After we save a new obect in the database we’ll eventually want to retrieve it and show it to the user again in their own timezone. We can do this with a custom Twig extension to convert times from UTC to the users timezone by passing it through a filter. First we create the extension.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
<?php
 
namespace SOTB\CoreBundle\Twig\Extension;
 
use Symfony\Component\DependencyInjection\ContainerInterface;
 
/**
* @author Matt Drollette <[email protected]>
*/
class LocaleExtension extends \Twig_Extension
{
protected $container;
protected $timezone;
 
public function __construct(ContainerInterface $container)
{
$this->container = $container;
$this->timezone = $this->container->get('session')->get('timezone', 'UTC');
}
 
public function getFunctions()
{
return array();
}
 
public function getFilters()
{
return array(
'datetime' => new \Twig_Filter_Method($this, 'formatDatetime', array('is_safe' => array('html'))),
);
}
 
public function formatDatetime($date, $timezone = null)
{
if (null === $timezone) {
$timezone = $this->timezone;
}
 
if (!$date instanceof \DateTime) {
if (ctype_digit((string) $date)) {
$date = new \DateTime('@'.$date);
} else {
$date = new \DateTime($date);
}
}
 
if (!$timezone instanceof \DateTimeZone) {
$timezone = new \DateTimeZone($timezone);
}
 
$date->setTimezone($timezone);
 
return $date;
}
 
public function getName()
{
return 'sotb_core_locale';
}
}
view raw LocaleExtension.php hosted with ❤ by GitHub

Then register the twig extension by tagging it with twig.extension

1 2 3 4
<service id="sotb_core.twig.extension" class="SOTB\CoreBundle\Twig\Extension\LocaleExtension">
<tag name="twig.extension"/>
<argument type="service" id="service_container"/>
</service>
view raw services-twig.xml hosted with ❤ by GitHub

Now we can format all our datetimes in the users timezone with this simple Twig filter

{{ invoice.dateOfIssue | datetime() }}

Author: Matt Drollette

I am a software developer in Dallas, TX.

8 Comments

  1. Awesome post!
    I just have a quick question. How do you get the timezone from the user (I mean getTimezone() in your User entity). Do you let the user fill the timezone in a form, or do you use DateTime::getTimezone?
    I am just wondering if the timezone of the user can be calculated “accurately” at each login instead of retrieving the Timezone from the database?

    Look forward to see your new posts. All the best. Sydney

    • I don’t know of a way to accurately and automatically get the users timezone. In my specific case I am dealing a lot with time-specific objects, events and whatnot, that all need to be accurate to the users local timezone. For this reason I explicitly require a timezone setting for all users. There may be a way to try and guess by IP geolookup, or using javascript to calculate time offset on the frontend and have that posted and compared on the backend, but I’ve never tried this and it seems error-prone.

      • I see. Thanks :-) The only thing that I was wondering here is that if the user changes the timezone in the form during the session, the timezone used in the website will still remain the one listened during login. What do you we here? Do you modify the timezone session value in the controller processing the form directly or do you use a form listener?

        • Yeah, I think this makes sense to do in the controller after persisting the user object.

          // persist $user

          $this->container->get(‘session’)->set(‘timezone’, $user->getTimezone());

          // redirect to success page

  2. Here you have a custom field ‘tz_timezone’ which handles the options.

    class TzDateTimeType extends DateTimeType implements ContainerAwareInterface {

    /** @var ContainerInterface */
    protected $container;

    public function setDefaultOptions(OptionsResolverInterface $resolver) {
    parent::setDefaultOptions($resolver);

    $resolver->replaceDefaults(array(
    ‘model_timezone’ => ‘UTC’,
    ‘view_timezone’ => $this->container->get(‘session’)->get(‘timezone’, ‘Europe/Berlin’),
    ));

    }

    public function getName() {
    return ‘tz_datetime’;
    }

    public function setContainer(ContainerInterface $container = null) {
    $this->container = $container;
    }

    }

  3. Thanks Matt. What format do you use for your field is in the database? (Im talking mysql here) A datetime field or a timestamp field, or even an int? I gather timestamps are subject to UTC/server time conversions automatically by mysql.

Leave a Reply

Required fields are marked *.