3

My app timezone is set to 'America/Montreal'.
I have a two datetime fields 'start' and 'end', each casted to datetime using laravel $casts property:

protected $casts = [
    'start' => 'datetime',
    'end' => 'datetime'
];

When I create an instance of my model with the following data:

MyModel::create(
                [
                    'start' => "2022-02-08T20:45:58.000Z", // UTC time  
                    'end' => "2022-02-08T20:45:58.000Z",
                ]
            );

The created model keeps the same time (20:45) but the timezone is set to America/Montreal:

 App\MyModel {#4799
     id: 44,
     created_at: "2022-02-08 15:49:02",
     updated_at: "2022-02-08 15:49:02",
     start: 2022-02-08 20:45:58,
     end: 2022-02-08 20:45:58,
   }

when I access the start and end attributes I get the same time but with America/Montreal timezone like this:

// accessing 'start' attribute of the instance I just created
Illuminate\Support\Carbon @1644371158 {#4708
 date: 2022-02-08 20:45:58.0 America/Montreal (-05:00),

}

The only way I found to get it to work correctly is to manually setTimezone before saving:

    MyModel::create(
                [
                    'start' => Carbon::parse("2022-02-08T20:45:58.000Z")->setTimeZone(config('app.timezone')),, 
                    'end' => Carbon::parse("2022-02-08T20:45:58.000Z")->setTimeZone(config('app.timezone')),,
                ]
            );  

which is, I think, repetitive, isn't setting the app timezone enough ? Is there any better way to do this ? I know that I'm supposed to set my app timezone to UTC (which is what I do usually) but this projet has already a lot of data with this timezone and I'm not sure how to convert it.
Thank you.

hereForLearing
  • 1,209
  • 1
  • 15
  • 33
  • How about using a mutator on model: https://laravel.com/docs/8.x/eloquent-mutators#defining-a-mutator – Abdul Rehman Feb 08 '22 at 21:17
  • 2
    Thank you for your answer. Yeah I did, but I would need to define one for every datetime attribute in every model, I hope that there is a better way to do this – hereForLearing Feb 08 '22 at 21:22
  • I see, have you tried this `protected $dateFormat = 'U';` on your model, from the same docs page under date casting section, though I've not tried this myself, seems like what you need – Abdul Rehman Feb 08 '22 at 21:27
  • If the above doesn't help, I would make use the a dynamic trait, so you don't have to do it manually, and the following SO answer does exactly that: https://stackoverflow.com/a/48371850/4311336 – Abdul Rehman Feb 08 '22 at 21:36
  • 1
    The point of setting it as a datetime in `$casts` is so you can pass `DateTime` or `Carbon` objects, not strings. Also why do you say "the timezone is set to America/Montreal" when you clearly show the date as "2022-02-08 20:45:58.0 +00:00"? – miken32 Feb 08 '22 at 21:56
  • @miken32 My bad, I've just correct the example. I'm not sure I understand what do you mean, I think that $casts are used so if you pass a string it's casted to a Datetime (mutator) and when you retrieve it from the database the string is casted to Datetime (accessor) – hereForLearing Feb 09 '22 at 16:48
  • @AbdulRehman thank you but I don't think that "protected $dateFormat = 'U';" solves the problem. The SO answer is interesting though. – hereForLearing Feb 09 '22 at 16:49
  • If you are passing a string, you may as well just pass a string the database understands, and not ask Laravel to cast it. The point of `$casts` is so you can do things like `$model->start = Carbon::now()` instead of `$model->start = '2022-02-09 12:43:42'`. The database doesn't store time zones, it will assume you're storing in whatever time zone it's configured for, which by default is your server's time zone. – miken32 Feb 09 '22 at 17:56
  • 2
    That's the problem, it stores it without doing timezone conversion to the app timezone and then retrieves it as if it was the app timezone, which is clearly a bug imo – Tofandel Aug 11 '22 at 13:04

1 Answers1

4

Laravel will just store the date in the model doing $date->format('Y-m-d H:i:s') which just uses the original hour/time of the date, but without preserving the timezone information.

Then when it retrieves it, because it's just a string with no timezone info, it will cast it to a carbon date with the app timezone (generally UTC)

This creates a disparity because you do not get the same value from the getter than the one you sent to the setter, if your date had a different timezone than the app

In simpler words, this is basically what happens

Carbon\Carbon::parse(Carbon\Carbon::parse('2022-11-08 00:00', 'America/Montreal')->format('Y-m-d H:i:s'), 'UTC');

Carbon\Carbon @1667865600 {#4115
   date: 2022-11-08 00:00:00.0 UTC (+00:00), // As you can see it is UTC,
  // which is ok because the database does not store the timezone information,
  // but the time is 2022-11-08 00:00 and should be 2022-11-08 05:00:00 in UTC
}

// This would yield the correct result
Carbon\Carbon::parse(Carbon\Carbon::parse('2022-11-08 00:00', 'America/Montreal')->setTimezone('UTC')->format('Y-m-d H:i:s'), 'UTC');

This is a very controversial problem in laravel which doesn't have a sane and expected handling of dates in model, which should have been to convert to the app timezone before casting the date to a string without timezone info, it was marked as "intended behavior"

To palliate this, you can make your own model extension overriding the setAttribute method and extend from this class instead to convert all the dates automatically to your app timezone

<?php

namespace App;

use DateTimeInterface;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model as BaseModel;

class Model extends BaseModel
{

    /**
     * Set a given attribute on the model.
     *
     * @param  string  $key
     * @param  mixed  $value
     * @return mixed
     */
    public function setAttribute($key, $value)
    {
        if ($value instanceof CarbonInterface) {
            // Convert all carbon dates to app timezone
            $value = $value->clone()->setTimezone(config('app.timezone'));
        } else if ($value instanceof DateTimeInterface) {
            // Convert all other dates to timestamps
            $value = $value->unix();
        }
        // They will be reconverted to a Carbon instance but with the correct timezone
        return parent::setAttribute($key, $value);
    }
}

Also do not forget to set your database timezone to your app timezone, otherwise if you store dates in timestamp instead of datetime you may get errors when trying to insert a date because the date may fall in a daylight saving time

In your config/database.php

    'connections' => [
        'mysql' => [
            //...
            'timezone'  => '+00:00', // Should match app.timezone
            //...

If you didn't do that before, you will need to migrate all your dates, here is a migration that does just that

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

class ConvertAllTimestampsToUtc extends Migration {
    public static function getAllTimestampColumns(): array
    {
        $results = DB::select(
            "SELECT TABLE_NAME, COLUMN_NAME from information_schema.columns WHERE DATA_TYPE LIKE 'timestamp' AND TABLE_SCHEMA LIKE :db_name", [
            'db_name' => DB::getDatabaseName()
        ]);

        return collect($results)->mapToGroups(fn($r) => [$r->TABLE_NAME => $r->COLUMN_NAME])->toArray();
    }

    public static function convertTzOfTableColumns($table, $columns, $from = '+00:00', $to = 'SYSTEM')
    {
        $q = array_map(fn($col) => "`$col` = CONVERT_TZ(`$col`, '$from', '$to')", $columns);
        DB::update("UPDATE `$table` SET " . join(', ', $q));
    }

    /**
     * Run the migrations.
     */
    public function up(): void
    {
        foreach (self::getAllTimestampColumns() as $table => $cols) {
            self::convertTzOfTableColumns($table, $cols);
        }
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        foreach (self::getAllTimestampColumns() as $table => $cols) {
            self::convertTzOfTableColumns($table, $cols, 'SYSTEM', '+00:00');
        }
    }
};
Tofandel
  • 3,006
  • 1
  • 29
  • 48