Timezones en Laravel definidos por el usuario

Share this:
Reading time: 7 minutes

Timezones en PHP y MySQL

La zona horaria definida se usará para las funciones de PHP de fechas y timestamps, y es importante por que no solo impactará en los cálculos de hora y fechas, por ejemplo al calcular la diferencia entre una fecha y hora dada, y ahora, sino que también en como los timestamps serán almacenados en MySQL, MariaDB u otros sistemas de base de datos.

La zona horaria de PHP se define en el archivo php.ini, por ejemplo:
[Date]
date.timezone = "America/Argentina/Buenos_Aires"

Y puede ser también definida a nivel de sistema usando:
date_default_timezone_set ('America/Argentina/Buenos_Aires');

Los diferentes timezones soportados por PHP pueden verse aquí.

En MySQL, en sistemas Linux, la zona horaria por defecto puede definirse de forma global en el archivo /etc/mysql/my.cnf, indicando la diferencia GMT de la zona horaria a definir:
[mysqld]
default-time-zone = "-03:00"

También puede definirse una variable global, por ejemplo:
sudo mysql -e "SET GLOBAL time_zone = '-03:00';"

Si se utiliza PDO, podemos también definir el timezone por conexión, indicando también el offset GMT:
$conn->exec("SET time_zone='-03:00';");


Timezones en Laravel

En Laravel, la zona horaria por defecto es UTC, y puede cambiarse modificando el valor de 'timezone' en el archivo config/app.php. Se accede a este valor con config('app.timezone');.

Si el sistema será utilizado por usuarios con diferentes zonas horarias (lo que es más probable), es una buena práctica usar UTC como la zona horaria de todo el sistema, de modo que todas las fechas y marcas de tiempo se almacenen en la base de datos como UTC, independientemente de la la zona horaria del usuario (que puede cambiar, por ejemplo, si el usuario viaja y accede desde otra ubicación), y luego convertir las marcas de tiempo a la zona horaria del usuario para que las visualice de acuerdo con su zona horaria establecida, pero siempre almacenando los timestamps de forma uniforme como UTC.

Aunque existen diferentes enfoques para abordar el manejo de zonas horarias en Laravel, este artículo intentará cubrir diferentes opciones globales para que un usuario pueda definir su zona horaria y visualizar las fechas y horas de acuerdo a sus preferencias.


Agregar la columna timezone en la tabla de usuarios

Como cada usuario podrá definir su zona horaria, debemos crear una nueva columna en la tabla de usuarios para almacenar esta información.

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');
        });
    }
}

En este enfoque, definimos la columna como nullable, ya que no queremos almacenar información innecesaria si el usuario selecciona UTC o la zona horaria predeterminada del sistema, y solo almacenaremos las zonas horarias diferentes a ésta, en caso de que el usuario lo defina.


Agregando un Accessor y Mutator de timezone en el Modelo de Usuarios

Luego de agregar timezone a $fillable[] en el modelo User, vamos a definir un Accessor y un Mutator para transformar este nuevo atributo en el modelo.

Como el campo timezone puede ser nulo (por ejemplo, si es igual a la zona horaria del sistema, o si agregamos esta funcionalidad en un sistema preexistente donde los usuarios no tienen una zona horaria asociada), vamos a crear una Accessor para que en caso de que la zona horaria del usuario esté vacía, recibamos la zona horaria por defecto del sistema al acceder a este atributo, evitando posibles errores.

En el modelo User definiremos:

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

Como no queremos almacenar en la DB zonas horarias similares a la definida por defecto en el sistema, también agregaremos un Mutator, por lo que si la zona horaria seleccionada por el usuario es igual a ésta, no se almacenará en la base de datos, y luego desde el Accessor previamente definido, la zona horaria predeterminada de la aplicación se devolverá correctamente al llamarla.

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

Helpers de Timezone

Para reutilizar el código relacionado con las zonas horarias, crearemos algunos Helpers.

Hay muchas formas de usar Helpers en Laravel, una posibilidad es crear, por ejemplo, un archivo en app/Helpers/Helpers.php con la estructura:

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

Para incluir esta clase y métodos en el bootstrap de la aplicación, agregaremos en el archivo composer.json, dentro de autoload y files, la referencia a esta nueva clase, por ejemplo:

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

Agregaremos también en el archivo config/app.php dentro de $aliases[] la referencia a esta clase:

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

En Helpers.php agregaremos dos nuevos Helpers relacionados con 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();
    });
}

Este nuevo método creará una colección ordenada con las zonas horarias disponibles (utilizadas como clave) y la zona horaria junto con el offset GMT, como se ve en la imagen a continuación.
Dado que esta información no cambiará, almacenaremos esta colección en caché permanentemente.

También podemos reemplazar este método con una versión simplificada y usar la función nativa de PHP timezone_identifiers_list(), que devolverá un array con todas las zonas horarias admitidas.

También agregaremos el método getUserTimeZone(), que intentará obtener la zona horaria definida por el usuario, si existe, y de lo contrario devolverá la zona horaria predeterminada de la aplicación.
Usaremos aquí el Helper de Laravel optional(), para evitar errores en caso de que el usuario no haya iniciado sesión.

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

Selección de zona horaria del usuario

Usando el Helper creado previamente, agregaremos el formulario donde el usuario puede definir su zona horaria. Por ejemplo:

<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>

Laravel tiene una regla de validación nativa para timezone, por lo que podremos fácilmente validar este campo en el controlador o en el archivo de form request con:

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


Obteniendo la zona horaria del usuario

Si queremos preseleccionar la zona horaria del usuario en el formulario, o agregarla como un campo oculto en el proceso de registro, podemos obtener la zona horaria actual del cliente de la biblioteca javascript moment.

Debemos cargar las librerías moment y moment-timezone, y usar el método moment.tz.guess(), por ejemplo:

<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>

Otra forma de acceder a esta información, sin el uso de bibliotecas externas pero que puede no ser compatible con navegadores antiguos es:

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


Utilizando la zona horaria del usuario en Controladores y Blade

Existen diferentes opciones para transformar fechas y marcas de tiempo utilizando la zona horaria del usuario, en controladores y archivos blade.

Si la sección siempre requiere que el usuario inicie sesión, podemos usar por ejemplo:
{{now(\Auth::user()->timezone)}}

O el Helper previamente creado, para contemplar también los casos en los que se accede sin iniciar sesión, devolviendo entonces la zona horaria predeterminada del sistema:
{{ now(Helpers::getUserTimeZone()) }}

Para parsear una marca de tiempo usando la zona horaria del usuario, simplemente usaremos:
Carbon::parse($model->timestamp_field)->setTimezone(Helpers::getUserTimeZone());


Guardar timestamps en la base de datos en UTC a partir de la zona horaria del usuario

Será común que el usuario agregue elementos timestamp, seleccionando la fecha y hora a partir de una librería javascript u otras, y usando su propia zona horaria, por lo que debemos transformar esta fecha a UTC para almacenarla de manera uniforme en nuestra base de datos.

Supongamos que el usuario puede agregar elementos y definir la fecha de creación, y tiene una zona horaria diferente a la del sistema, una forma de transformarlo antes de guardarlo es:

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

De esta forma indicamos al método de Carbon parse() que la zona horaria de la marca de tiempo dada es de un tipo, y que la transformaremos a UTC, o a la zona horaria predeterminada de nuestra aplicación.

Es importante que esta transformación se realice luego de la validación del formulario, ya que de lo contrario, si falla la validación, al llamar a old() desde la vista, el usuario verá la fecha en UTC y no en su zona horaria.

Si el usuario puede editar posteriormente este elemento, también querremos que se muestre en su zona horaria, por lo que lo transformaremos antes de enviarlo a la vista:

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

Accessors de timezone de usuario en Modelos

Si queremos que ciertos atributos se devuelvan por defecto con la zona horaria del usuario (o el predeterminado por el sistema si no existe o no está definido, o no está registrado), podemos definir accessors en el modelo:

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 fechas con la zona horaria del usuario

Para simplificar la transformación de timestamps con la zona horaria del usuario en Blade, podemos crear un Componente de Blade.

php artisan make:component DateTimeZone --inline

Usamos el flag –inline para evitar crear un archivo de vista, dado que por la simplicidad de este componente no es necesario, y la fecha transformada será devuelta directamente desde el método render() de la nueva clase creada en app/View/Components/DateTimeZone.php.

The code of this file will be:

<?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 se define como mixed, ya que puede ser de tipo string al definir un formato, o null si este parámetro opcional no está presente.
Nuevamente utilizaremos el helper getUserTimeZone(), para obtener la zona horaria definida por el usuario, o la predeterminada en el sistema.

Desde Blade, podemos utilizar este componente pasando el timestamp a transformar, y opcionalmente indicando un formato de salida, que por defecto y si este parámetro no está 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"/>

Otros enfoques no recomendados

En algunos enfoques para trabajar con múltiples zonas horarias en Laravel que he visto en línea, se crea un middleware donde la zona horaria predeterminada del sistema se establece en función de la zona horaria del usuario con date_default_timezone_set().

Esto es algo que no se recomienda, ya que de esta forma se modifica la zona horaria de todo el sistema para cada usuario, y se guardarían en la base de datos marcas de tiempo no uniformes, con diferentes zonas horarias.

Con el enfoque y los métodos de este artículo, podemos trabajar con múltiples zonas horarias, definidas por el usuario, pero almacenadas uniformemente en UTC u otra zona horaria definida por defecto en la aplicación, permitiendo que la misma información se visualice de manera diferente pero sin alterar su integridad.


Share this: