solutiofy

Timezones no Laravel definidos pelo usuário

13 de agosto de 2021

Timezones no Laravel definidos pelo usuário

Timezones em PHP e MySQL

O fuso horário definido será usado pelas funções de data e timestamp do PHP, e é importante porque não apenas impactará nos cálculos de hora e data, por exemplo ao calcular a diferença entre uma data e hora informada e o momento atual, mas também na forma como os timestamps serão armazenados no MySQL, MariaDB ou outros sistemas de banco de dados.

O fuso horário do PHP é definido no arquivo php.ini, por exemplo:
[Date]
date.timezone = "America/Argentina/Buenos_Aires"

E também pode ser definido em nível de sistema usando:
date_default_timezone_set ('America/Argentina/Buenos_Aires');

Os diferentes timezones suportados pelo PHP podem ser vistos aqui.

No MySQL, em sistemas Linux, o fuso horário padrão pode ser definido de forma global no arquivo /etc/mysql/my.cnf, indicando a diferença GMT do fuso horário a ser definido:
[mysqld]
default-time-zone = "-03:00"

Também é possível definir uma variável global, por exemplo:
sudo mysql -e "SET GLOBAL time_zone = '-03:00';"

Se utilizamos PDO, também podemos definir o timezone por conexão, indicando igualmente o offset GMT:
$conn->exec("SET time_zone='-03:00';");


Timezones no Laravel

No Laravel, o fuso horário padrão é UTC, e pode ser alterado modificando o valor de 'timezone' no arquivo config/app.php. Esse valor é acessado com config('app.timezone');.

Se o sistema for utilizado por usuários com diferentes fusos horários (o que é o mais provável), é uma boa prática usar UTC como o fuso horário de todo o sistema, de modo que todas as datas e marcas de tempo sejam armazenadas no banco de dados como UTC, independentemente do fuso horário do usuário (que pode mudar, por exemplo, se o usuário viajar e acessar de outra localização), e depois converter os timestamps para o fuso horário do usuário para que ele os visualize de acordo com o fuso horário configurado, mas sempre armazenando os timestamps de forma uniforme como UTC.

Embora existam diferentes abordagens para lidar com o gerenciamento de fusos horários no Laravel, este artigo tentará cobrir diferentes opções globais para que um usuário possa definir seu fuso horário e visualizar as datas e horas de acordo com suas preferências.


Adicionar a coluna timezone na tabela de usuários

Como cada usuário poderá definir seu fuso horário, precisamos criar uma nova coluna na tabela de usuários para armazenar essa informação.

php artisan make:migration add_timezone_field_to_users_table --table=users
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class AddTimezoneFieldToUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('timezone', 40)->nullable();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('timezone');
        });
    }
}

Nesta abordagem, definimos a coluna como nullable, já que não queremos armazenar informações desnecessárias se o usuário selecionar UTC ou o fuso horário padrão do sistema, e armazenaremos apenas os fusos horários diferentes deste, caso o usuário o defina.


Adicionando um Accessor e um Mutator de timezone no Model de Usuários

Depois de adicionar timezone a $fillable[] no model User, vamos definir um Accessor e um Mutator para transformar esse novo atributo no model.

Como o campo timezone pode ser nulo (por exemplo, se for igual ao fuso horário do sistema, ou se adicionarmos essa funcionalidade a um sistema preexistente em que os usuários não têm um fuso horário associado), vamos criar um Accessor para que, caso o fuso horário do usuário esteja vazio, recebamos o fuso horário padrão do sistema ao acessar esse atributo, evitando possíveis erros.

No model User definiremos:

public function getTimeZoneAttribute ($value): string
{
  return $value == config('app.timezone') || empty($value) ? config('app.timezone') : $value;
}

Como não queremos armazenar no banco de dados fusos horários iguais ao definido por padrão no sistema, também adicionaremos um Mutator; assim, se o fuso horário selecionado pelo usuário for igual a este, ele não será armazenado no banco de dados, e depois, a partir do Accessor definido anteriormente, o fuso horário padrão da aplicação será retornado corretamente ao acessá-lo.

public function setTimeZoneAttribute($value) {
  $this->attributes['timezone'] = $value == config('app.timezone') || is_null($value) ? null : $value;
}

Helpers de Timezone

Para reutilizar o código relacionado aos fusos horários, criaremos alguns Helpers.

Há muitas formas de usar Helpers no Laravel; uma possibilidade é criar, por exemplo, um arquivo em app/Helpers/Helpers.php com a estrutura:

<?php
namespace App\Helpers;
use Illuminate\Support\Facades\App;
class Helpers {
  //...
}

Para incluir essa classe e seus métodos no bootstrap da aplicação, adicionaremos no arquivo composer.json, dentro de autoload e files, a referência a essa nova classe, por exemplo:

"files": [
   "app/Helpers/Helpers.php"
]

Também adicionaremos no arquivo config/app.php, dentro de $aliases[], a referência a essa classe:

'aliases' => [ 
  	//..
	'Helpers' => App\Helpers\Helpers::class, 
],

Em Helpers.php adicionaremos dois novos Helpers relacionados a timezone:

static public function getTimeZoneList()
{
    return \Cache::rememberForever('timezones_list_collection', function () {
        $timestamp = time();
        foreach (timezone_identifiers_list(\DateTimeZone::ALL) as $key => $value) {
            date_default_timezone_set($value);
            $timezone[$value] = $value . ' (UTC ' . date('P', $timestamp) . ')';
        }
        return collect($timezone)->sortKeys();
    });
}

Esse novo método criará uma collection ordenada com os fusos horários disponíveis (utilizados como chave) e o fuso horário junto com o offset GMT, como mostra a imagem a seguir.
Como essa informação não mudará, armazenaremos essa collection em cache permanentemente.

Também podemos substituir esse método por uma versão simplificada e usar a função nativa do PHP timezone_identifiers_list(), que retornará um array com todos os fusos horários suportados.

Também adicionaremos o método getUserTimeZone(), que tentará obter o fuso horário definido pelo usuário, se existir, e caso contrário retornará o fuso horário padrão da aplicação.
Usaremos aqui o Helper do Laravel optional(), para evitar erros caso o usuário não tenha feito login.

static public function getUserTimeZone() {
    return optional(auth()->user())->timezone ?? config('app.timezone');
}

Seleção do fuso horário do usuário

Usando o Helper criado anteriormente, adicionaremos o formulário em que o usuário pode definir seu fuso horário. Por exemplo:

<div class="col-md-4">
    <label class="required" for="timezone">{{ __('TimeZone') }}</label>
    <select class="form-control" name="timezone" id="timezone">
        @foreach(Helpers::getTimeZoneList() as $timezone => $timezone_gmt_diff)
            <option value="{{ $timezone }}" {{ ( $timezone === old('timezone', $user->timezone)) ? 'selected' : '' }}>
                {{ $timezone_gmt_diff }}
            </option>
        @endforeach
    </select>
</div>

O Laravel tem uma regra de validação nativa para timezone, portanto poderemos validar facilmente esse campo no controller ou no arquivo de form request com:

timezone' => ['required', 'timezone']


Obtendo o fuso horário do usuário

Se quisermos pré-selecionar o fuso horário do usuário no formulário, ou adicioná-lo como um campo oculto no processo de registro, podemos obter o fuso horário atual do cliente com a biblioteca javascript moment.

Devemos carregar as bibliotecas moment e moment-timezone, e usar o método moment.tz.guess(), por exemplo:

<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.33/moment-timezone-with-data.min.js"></script>
<script>
    $(document).ready(function() {
        console.log(moment.tz.guess());
    }); 
</script>

Outra forma de acessar essa informação, sem o uso de bibliotecas externas, mas que pode não ser compatível com navegadores antigos, é:

Intl.DateTimeFormat().resolvedOptions().timeZone;


Utilizando o fuso horário do usuário em Controllers e Blade

Existem diferentes opções para transformar datas e marcas de tempo utilizando o fuso horário do usuário, em controllers e arquivos blade.

Se a seção sempre exigir que o usuário esteja logado, podemos usar, por exemplo:
{{now(\Auth::user()->timezone)}}

Ou o Helper criado anteriormente, para contemplar também os casos em que se acessa sem login, retornando então o fuso horário padrão do sistema:
{{ now(Helpers::getUserTimeZone()) }}

Para fazer o parse de um timestamp usando o fuso horário do usuário, simplesmente usaremos:
Carbon::parse($model->timestamp_field)->setTimezone(Helpers::getUserTimeZone());


Salvar timestamps no banco de dados em UTC a partir do fuso horário do usuário

Será comum que o usuário adicione elementos com timestamp, selecionando a data e a hora a partir de uma biblioteca javascript ou outras, e usando seu próprio fuso horário, portanto precisamos transformar essa data para UTC para armazená-la de maneira uniforme em nosso banco de dados.

Suponhamos que o usuário possa adicionar elementos e definir a data de criação, e que tenha um fuso horário diferente do sistema; uma forma de transformá-la antes de salvar é:

$request->merge([
  'created_at' => Carbon::parse($request->input('created_at'), Helpers::getUserTimeZone())
  ->setTimeZone(config('app.timezone'))
  ->format('Y-m-d H:i:s'),
]);

Dessa forma indicamos ao método parse() do Carbon que o fuso horário do timestamp informado é de um tipo, e que o transformaremos para UTC, ou para o fuso horário padrão da nossa aplicação.

É importante que essa transformação seja realizada após a validação do formulário, pois caso contrário, se a validação falhar, ao chamar old() na view, o usuário verá a data em UTC e não em seu fuso horário.

Se o usuário puder editar posteriormente esse elemento, também vamos querer que ele seja exibido em seu fuso horário, portanto o transformaremos antes de enviá-lo para a view:

$model->created_at = Carbon::parse($model->created_at)->setTimezone(Helpers::getUserTimeZone());

Accessors de timezone do usuário em Models

Se quisermos que certos atributos sejam retornados por padrão com o fuso horário do usuário (ou o padrão do sistema se não existir, não estiver definido ou o usuário não estiver logado), podemos definir accessors no model:

public function getCreatedAtAttribute($value): Carbon
{
    return Carbon::parse($value)->timezone(Helpers::getUserTimeZone());
}

public function getUpdatedAtAttribute($value): Carbon
{
    return Carbon::parse($value)->timezone(Helpers::getUserTimeZone());
}

Componente Blade para datas com o fuso horário do usuário

Para simplificar a transformação de timestamps com o fuso horário do usuário no Blade, podemos criar um Componente Blade.

php artisan make:component DateTimeZone --inline

Usamos a flag --inline para evitar criar um arquivo de view, já que, pela simplicidade deste componente, isso não é necessário, e a data transformada será retornada diretamente do método render() da nova classe criada em app/View/Components/DateTimeZone.php.

O código deste arquivo será:

<?php

namespace App\View\Components;

use App\Helpers\Helpers;
use Carbon\Carbon;
use Illuminate\View\Component;

class DateTimeZone extends Component
{

    public Carbon $date;
    public mixed $format;

    /**
     * Create a new component instance.
     *
     * @return void
     */
    public function __construct(Carbon $date, $format = null)
    {
        $this->date = $date->setTimezone(Helpers::getUserTimeZone());
        $this->format = $format;
    }

    protected function format()
    {
        return $this->format ?? 'Y-m-d H:i:s';
    }

    /**
     * Get the view / contents that represent the component.
     *
     * @return \Illuminate\Contracts\View\View|\Closure|string
     */
    public function render()
    {
        return $this->date->format($this->format());
    }
}

$format é definido como mixed, já que pode ser do tipo string ao definir um formato, ou null se esse parâmetro opcional não estiver presente.
Novamente utilizaremos o helper getUserTimeZone(), para obter o fuso horário definido pelo usuário, ou o padrão do sistema.

No Blade, podemos utilizar esse componente passando o timestamp a ser transformado e, opcionalmente, indicando um formato de saída, que por padrão, se esse parâmetro não estiver presente, será Y-m-d H:i:s.

<x-date-time-zone :date="$model->created_at" />

<x-date-time-zone :date="$model->created_at" format="d-m-Y H:i:s"/>

<x-date-time-zone :date="$model->created_at" format="d/m/Y<\b\r>H:i"/>

Outras abordagens não recomendadas

Em algumas abordagens para trabalhar com múltiplos fusos horários no Laravel que vi online, cria-se um middleware em que o fuso horário padrão do sistema é definido com base no fuso horário do usuário com date_default_timezone_set().

Isso não é recomendado, já que dessa forma o fuso horário de todo o sistema é modificado para cada usuário, e seriam salvos no banco de dados timestamps não uniformes, com diferentes fusos horários.

Com a abordagem e os métodos deste artigo, podemos trabalhar com múltiplos fusos horários, definidos pelo usuário, mas armazenados uniformemente em UTC ou em outro fuso horário definido por padrão na aplicação, permitindo que a mesma informação seja visualizada de maneira diferente, mas sem alterar sua integridade.