Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to handle Laravel application across multiple timezones

I've been reading up about the best approach to handling localised times, when a Laravel application is used across multiple timezones.

My understanding is that the app timezone should remain set as the default, which is UTC.

This means that all datetime / timestamps are recorded in the database (MySQL in my case) as their UTC value - in other words, consistently.

For Eloquent models to have the correct (localised) date / time values, the user's timezone must be obeyed. It is at this point that I am less clear on how to proceed - specifically, in terms of:

  • How the user's timezone is best obtained
  • How this timezone can be used in a transparent way with Eloquent, so that
    • All model dates are output in local time
    • All dates are recorded in the database correctly (as UTC)

Edit I should mention that my app supports both anonymous and authenticated users, so I don't want to force the user to explicitly select their timezone.

like image 240
BrynJ Avatar asked Aug 13 '18 14:08

BrynJ


1 Answers

I ended up implementing this with my own model trait, primarily because I needed to implement this in a transparent way.

Firstly, I created my own getAttribute() method, to retrieve the stored values (stored as the app's default timezone - likely UTC) and then apply the current timezone.

The trait also alters the model's create() and update() methods, to support fields in a model's dates property being stored as the app's timezone, when they've been set by the user in the current active timezone.

The self::getLocale() static method in the trait in my case is provided by another trait in my app, although this logic can be adjusted to suit your own app.

trait LocalTime
{
    /**
     * Override create() to save user supplied dates as app timezone
     * 
     * @param array      $attributes
     * @param bool|mixed $allow_empty_translations
     */
    public static function create(array $attributes = [], $allow_empty_translations=false)
    {
        // get empty model so we can access properties (like table name and fillable fields) that really should be static!
        // https://github.com/laravel/framework/issues/1436
        $emptyModel = new static;

        // ensure dates are stored with the app's timezone
        foreach ($attributes as $attribute_name => $attribute_value) {
            // do we have date value, that isn't Carbon instance? (assumption with Carbon is timezone value will be correct)
            if (!empty($attribute_value) && !$attribute_value instanceof Carbon && in_array($attribute_name, $emptyModel->dates)) {
                // update attribute to Carbon instance, created with current timezone and converted to app timezone
                $attributes[$attribute_name] = Carbon::parse($attribute_value, self::getLocale()->timezone)->setTimezone(config('app.timezone'));
            }
        }

        // https://github.com/laravel/framework/issues/17876#issuecomment-279026028
        $model = static::query()->create($attributes);

        return $model;
    }

    /**
     * Override update(), to save user supplied dates as app timezone
     *
     * @param array $attributes
     * @param array $options
     */
    public function update(array $attributes = [], array $options = [])
    {
        // ensure dates are stored with the app's timezone
        foreach ($attributes as $attribute_name => $attribute_value) {
            // do we have date value, that isn't Carbon instance? (assumption with Carbon is timezone value will be correct)
            if (!empty($attribute_value) && !$attribute_value instanceof Carbon && in_array($attribute_name, $this->dates)) {
                // update attribute to Carbon instance, created with current timezone and converted to app timezone
                $attributes[$attribute_name] = Carbon::parse($attribute_value, self::getLocale()->timezone)->setTimezone(config('app.timezone'));
            }
        }

        // update model
        return parent::update($attributes, $options);
    }

    /**
     * Override getAttribute() to get times in local time
     *
     * @param mixed $key
     */
    public function getAttribute($key)
    {
        $attribute = parent::getAttribute($key);

        // we apply current timezone to any timestamp / datetime columns (these are Carbon objects)
        if ($attribute instanceof Carbon) {
            $attribute->tz(self::getLocale()->timezone);
        }

        return $attribute;
    }
}

I'd be interested in feedback of the above approach.

like image 199
BrynJ Avatar answered Nov 09 '22 10:11

BrynJ