Потребность во временной локализации продукта возникает, когда продукт вырастает до таких масштабов, при которых необходима работа в разных временных зонах (очевидность). Хочется описать вариант простой идеи решения этого кейса.

Предыстория такова: разрабатывали нишевую CRM/ERP-систему, а потом нам сказали, что буквально завтра с этой системой будут работать по франшизе от Владивостока до Калининграда. К сожалению, изначально такой сценарий продуман не был, и мы начали изучать, как сделать это сделать с минимальными затратами и максимальным удобством.

Итого, укрупненно получилось три задачи: как мы выводим данные и как вводим, а между ними задача как все это храним. Поскольку время, как известно, относительно в прямом и переносном смысле, было решено хранить время как раньше по Москве UTC+3, но обрабатывать его на входе и выходе (и везде иметь в виду, что точка отсчета — UTC+3). Конечно, мы понимали, что есть и другие решения в этом и других направлениях. Можно преобразовать все существующие записи к UTC+-0, а также использовать специализированные типы в СУБД, которые хранят временную зону, можно самим написать этот кастомный тип, если вдруг база не в полной мере поддерживает такие фичи. Но руководствуясь принципом простоты, пошли по предложенному пути, тем более, он, на первый взгляд, существенно ничем не проигрывает остальным, и логика по определению нужной временной зоны была довольно проста.

После того как точкой отсчета стала Москва, добавили настраеваемый параметр временной зоны каждому пользователю, а также — в ряд связанных сущностей (организация, город, заявки, сделки и т.д.). После чего можно было однозначно устанавливать, в каком временном поясе пользователь или сущность, с которой он работает. Логика там стандартная и точно часто специфичная для проектов. Обернули эту логику в сервис и получали временную зону, где нужно

$localizationService->getTimezone();

Решение по локализации дат в шаблонах было следующим: при инициализации Twig расширений меняли временную зону на нужную:

function __construct(Environment $twig, LocalizationService $localizationService) {
    $twig->getExtension('Twig_Extension_Core')->setTimezone($localizationService->getTimezone());
}

Наша ситуация осложнялась еще тем, что после вывода любой даты-времени необходимо делать приписку «01.01.2020 12:30 (Москва)», чтобы, например, в условной заявке/задаче/сделке, которая привязана к часовому поясу, выводилась информация о часовом поясе. Из практических соображений это нужно, чтобы единый колл-центр мог комфортно работать с разными временными зонами в рамках задачи/заявки/сделки.

Вся логика по определению приоритета временных зон была зашита в вышеупомянутый getTimezone.

Далее столкнулись с тем, что если делать свой twig-фильтр или -функцию, то необходимо будет изменять кучу шаблонов, а этого хотелось избежать. Поэтому, немного приценившись, мы решили переопределить стандартный twig-фильтр date

...
new TwigFilter('date', [$this, 'date'], ['needs_environment' => true]),
...

function date(Twig_Environment $env, $date, $format = null, $timezone = null)
{
    $appendix = '';
    if (format && strpos($format, 'H:i') !== false)
        $appendix = ' ('.DateTimeFunctions::getRussianAbbrev($this->localizationService->getTimezone()).')';   
...
    // стандартный код фильтра date записывающийся в $result
...
   return $result.$appendix;
}

Также раз мы заняли стандартный фильтр, старую версию определили заново:

...
new TwigFilter('native_date', [$this, 'nativeDate'], [ 'needs_environment' => true]),
...
public function nativeDate(Twig_Environment $env, $date, $format = null, $timezone = null)
{
    // стандартный код фильтра date 
}

Стандартный код фильтра date можно найти в /twig/twig/lib/Twig/Extension/Core::twig_date_format_filter. Хотя на самом деле в большинстве случаев сгодится простейший, не сильно отличающийся вариант:

$date->setTimeZone($timezone)
$result = $date->format($format);

Конечно, также можно сделать форк или переопределить более существенную часть Twig, но если функционал стандартного фильтра устраивает, то можно просто вынести его отдельно и ничего не потерять.

Осталось решить проблему ввода даты-времени. Один из вариантов решения:

private function getOffsetHours()
{
    if (!$this->isInit)
        $this->init();

    $local = new \DateTime('now', new \DateTimeZone($this->getTimezone()));
    $user = new \DateTime('now');

    $localOffset = $local->getOffset() / 3600;
    $globalOffset = $user->getOffset() / 3600;

    $diff = $globalOffset - $localOffset;
    return $diff;
}

public function toGlobalTime(\DateTimeInterface $dateTime): \DateTimeInterface 
{
    if (!$this->isInit)
        $this->init();

    $offsetHours = $this->getOffsetHours();

    if ($offsetHours > 0) {
        return $dateTime->modify('+ '.$offsetHours.' hours');
    } else  if($offsetHours < 0){
        return $dateTime->modify($offsetHours.' hours');
    }

    return $dateTime;
}

После чего вызывать перед сохранением даты-времени, например, в слушателях. Нам этот вариант подошел, так как в основном время и дата в проекте фиксируются по определенным событиям, а не вводятся вручную. Для другого крайнего случая, где постоянно вводится время через формы, возможно, решение будет неоптимальным.

В качестве бонуса. В проекте для вывода таблиц используется Omines datatables-bundle. Там решение оказалось еще проще. Вместо DateTimeColumn для локализации использовался:

class CustomDateTimeColumn extends DateTimeColumn
{
    
    private $localizationService;
    private $timeZone;
    
    public function __construct(LocalizationService $localizationService)
    {
        $this->localizationService = $localizationService;
        $this->timeZone = $localizationService->getTimezoneObject();
    }
    
   
    public function normalize($value)
    {
        $value->setTimeZone($this->timeZone);
        return parent::normalize($value);
    }
}

Спасибо за потраченное время. Если кто-то поможет улучшить базовые вещи решения, то буду весьма благодарен. Речь идет про базовые, так как понятно, что код вакуумный и в реальности имеет куда больший DI и всякие плюшки для внутреннего пользования в проекте.

Резюмируя. Представлена идея простого решения по быстрой временной локализации проекта. От версий не зависит или если зависит, то слабо. Это решение успешно перекочевало из Symfony 4.2 в 5.