User defined timezones in Laravel

Share this:
Reading time: 7 minutes

Timezones in PHP and MySQL

The defined time zone will be used for the date and datetime PHP functions, and it is important as it will impact not only on the date and time calculations, for example on the calculation of differences between a date and now, but also on how timestamps will be stored in MySQL, MariaDB or other database systems.

The PHP time zone is defined in the php.ini file, for example:
[Date]
date.timezone = "America/Argentina/Buenos_Aires"

And it can also be defined at the system level using:
date_default_timezone_set ('America/Argentina/Buenos_Aires');

You can see the timezones supported by PHP here.

In MySQL, on Linux systems the default timezone can be defined globally in the /etc/mysql/my.cnf file, indicating the GMT difference of the desired zone:
[mysqld]
default-time-zone = "-03:00"

A global variable can also be defined, for example:
sudo mysql -e "SET GLOBAL time_zone = '-03:00';"

If we use PDO, we can also define the timezone per connection, also indicating the GMT difference:
$conn->exec("SET time_zone='-03:00';");


Timezones in Laravel

In Laravel, the default timezone is set to UTC, although a different one can be defined by modifying the value of 'timezone' in config/app.php. You can access to this value with config('app.timezone');.

If the system will be used by users with different time zones (which is most likely), it’s a good practice to use UTC as the system-wide timezone, so that all dates and timestamps are stored in the database as UTC, regardless of the user’s time zone (which can change, for example if the user travels and accesses from another location), and then convert the timestamps to the user’s timezone so that he sees them according to his established time zone, but always keeping uniform saving timestamps as UTC.

Although there are different approaches to address this issue, this article will try to cover different global options so that a user can define their time zone, and display the dates and times according to his preferences.


Add timezone column in users table

As each user will be able to define his time zone, we must create a new column in the users table to store this information.

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

In this approach, we define the column as nullable, since we don’t want to store unnecessary information if the user selects UTC or the system default time zone, and we will only store the different timezones, in case the user defines it.


Adding User Model timezone Accessor and Mutator

After adding timezone to $fillable[] in the User model, we will define an accessor and mutator for setting and getting this new model attribute.

As in this approach the timezone field can be null (for example if it is equal to the system timezone, or if we add this functionality in a pre-existing system where users do not have an associated timezone), we are going to create an Accessor so that in case of that the user’s timezone is empty, we receive the system’s default timezone when accessing this attribute, avoiding possible errors.

In the user model we will define:

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

As we do not want to store in the DB timezones similar to the one defined by default, we will also add a Mutator, so if the timezone selected by the user is equal to this, it will not be stored in the database, and then from the previously defined Accessor, the application’s default timezone will be correctly returned when calling it.

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

Timezone Helpers

To reuse the code related to timezone we will create some helpers.

There are many ways to use helpers, one possibility is to add for example a file in app/Helpers/Helpers.php with the structure:

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

To include this class and methods in the app’s bootstrap, we will add in the composer.json file, inside autoload and files, the reference to this new class, for example:

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

We will also add in config/app.php inside $aliases[] the reference to this class:

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

In Helpers.php we will add two new helpers related to 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();
    });
}

This new method will create an ordered collection with the available time zones (used as a key), and the timezone together with the GMT offset , as seen in the image below.
Since this information will not change, we will permanently cache this collection.

We can also replace this method with a simplified version and use the native PHP function timezone_identifiers_list(), which will return an array with all supported timezones.

We will also add the getUserTimeZone() method, which will try to obtain the timezone defined by the user, if it exists, and otherwise it will return the application’s default timezone.
We will use the Laravel optional() Helper here, to avoid errors in case the user is not logged in.

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

User’s timezone selection

Using the previously created Helper, we will add the form where the user can define their timezone. For example:

<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 has a native validation rule for timezone, so we can easily validate it in the controller or form request file with:

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


Getting the user’s timezone

If we want to preselect the user’s time zone in the form, or add it as a hidden field in the registration process, we can get the current time zone of the client from the JS moment library.

We must have the moment and moment-timezone libraries, and use the moment.tz.guess() method, for example:

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

Another way to access this information, without the use of external libraries but which may not be compatible with older browsers is:

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


Using the user’s timezone on controllers and blade

There are different options to transform dates and timestamps using the user’s timezone, in controllers and blade files.

If the section always requires the user to be logged in, we can use for example:
{{now(\Auth::user()->timezone)}}

Or the Helper, to also contemplate cases in which it is accessed without being logged in, then returning the system default timezone:
{{ now(Helpers::getUserTimeZone()) }}

To parse a timestamp using the user’s timezone, we will simply use:
Carbon::parse($model->timestamp_field)->setTimezone(Helpers::getUserTimeZone());


Saving timestamps in the database in UTC from the user’s timezone

It will be common for the user to add timestamp elements, selecting the date and time from a javascript library or others, and using their own time zone, so we must transform this date into UTC to store it uniformly in our database.

Suppose the user can add items and define the creation date, and has a different timezone than the system, one way to transform before saving it would be:

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

In this way we indicate to parse that the timezone of the given timestamp is of a type, and that we will transform it to UTC, or to the default timezone of our application.

It is important that this transformation is carried out after the validation of the form, since otherwise, if validation fails, when calling old() from the view, the user will see the date in UTC and not in his timezone.

If the user can later edit this element, we will also want it to be shown in their timezone, so we will transform it before sending to the view:

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

Models user timezone Accessors

If we want certain timestamp attributes to be returned by default with the user’s timestamp (or the default by the system if it does not exist or is not defined, or is not logged in), we can define accessors in the 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());
}

Blade Component for Dates with the user’s timezone

To simplify the transformation of timestamps with the user’s timezone in Blade, we can create a Blade component.

php artisan make:component DateTimeZone --inline

We use –inline to avoid creating a view, since for the simplicity of this component it is not necessary, and the transformed date will be returned directly from the render() method of the new class created in 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 is defined as mixed, since it can be of type string when defining a format, or null if this optional parameter is not present.
We will use again the getUserTimeZone() helper, to obtain the timezone defined by the user, or the system default.

From Blade, we can use this component passing the timestamp to transform, and optionally indicating an output format, which by default will be if this parameter is not present 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"/>

In some approaches to working with multiple timezones in Laravel that I have seen online, a middleware is created where the system default timezone is set based on the user’s timezone with date_default_timezone_set().

This is something that is not recommended, since in this way the timezone of the entire system is modified for each user, and non-uniform timestamps, with different timezones, would be saved in the database.

With the approach and methods of this article, we can work with multiple timezones, defined by the user, but stored uniformly in UTC or another timezone defined by default in the application, allowing the same information to be displayed differently without altering its integrity.


Share this: