Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

jrean's avatar

MyApp, UTC, User's Timezone, My Nightmare

Hi,

I'm in the middle of a nightmare, where monsters are Carbon dates, UTC, timezones and objects, so please help me to wake up :)

This project is my first dealing with timezone and I had really a very hard time. Everything is quite almost done and I just discovered yesterday while testing that my queries are good but biased because of my timezone lake of experience...

So, again:

  • I store everything in UTC
  • a User has a Profile where I store its timezone.
  • a User can add many Records a day (not sure at all but for now, I'm also storing for each Record the user's timezone)
  • for the abstraction purpose lets say for instance a User adds Note records (9/10 ; 8.5/10 ; ...)

Let's dig into my issue:

  • With my test user's timezone I added a first record on 2015-07-19 and the record in UTC was also the 2015-07-19.

  • With my test user's timezone I added (later) a second record on 2015-07-20 (it was something like 01:00:00 in the night) so the record in UTC was still in the previous day 2015-07-19 because my test user's timezone is UTC+8

  • In my app I need to display some insights and so I need to build my queries.. Until yesterday I was doing something like the following code to fetch the "last record for today"

    public function lastForProfileToday(XxxProfile $profile, $columns = ['*'])
    {
        $today = Carbon::now()->toDateString();

        return $this->model
            ->where('xxx_profile_id', $profile->id)
            ->where(DB::raw('date(created_at)'), '=', $today)
            ->orderBy('created_at', 'desc')
            ->limit(1)
            ->first($columns);
    }

If the query seems correct I sadly discovered the logic is not at all. Do you remember, the second record added by my test user in its timezone was 2015-07-20 but in the database it was stored in UTC as 2015-07-19.

The query will return all records for that user for 2015-07-19 which is not correct because at the time of the query the user date was 2015-07-20. I could still add more logic after the query to extract only related records corresponding to the user timezone but it sucks, right?

  • So I need to deal with the user's timezone in my query! I tried...
    public function lastForProfileToday(XxxProfile $profile, $columns = ['*'])
    {
        $today = Carbon::now($profile->timezone)->toDateString();

        return $this->model
            ->where('xxx_profile_id', $profile->id)
            ->where(DB::raw('date(created_at)'), '=', $today)
            ->orderBy('created_at', 'desc')
            ->limit(1)
            ->first($columns);
    }

At the time of the query today was 2015-07-20 based on the test user's timezone. So this query should return the last record added by the test user for today but of course it won't return anything because the records in UTC are 2015-07-19

Hum ... Do you see how lost I am? Are you laughing? :D

Don't give up... (talking to myself :D)

First conclusion: I also need to take care of the time and not only of the date! Right? Is it the good path?

A good busy soul told me on Slack's Larachat:

You should query against time and not just day. So if you wish to retrieve all records for the user’s 2015-07-20, and they are offset by 6h from UTC, then query on all records between 2015-07-19 18:00:00 and 2015=07=20 18:00:00.

Great, but how to...

Can someone please take the time to help me, teach me and make me say "Ahhhhhhhhhhhh"!

I think I understand the logic but I have a hard time to implement it and of course my app uses something like 20 insights like that so I will need to replicate the logic everywhere and for now, I'm totally stuck and this is a real nightmare.

Thank you in advance :)

Update: I don't know at all if this is the solution neither if it is the best way to handle the situation but what if I do something like.

  • Get the current date of the user based on its timezone with $today = Carbon::today($timezone);
  • Convert to UTC based on the offset. For instance if the user is UTC + 8 I will do $from = $today->subHours($offset); //$offset = 8 and $to = $from->addDay();

So I could update my query like:

    public function lastForProfileToday(XxxProfile $profile, $columns = ['*'])
    {
    $today = Carbon::today($profile->timezone);

    // $offset = 0
    //$from = $today
    
        // $offset > 0
    $from = $today->subHours($profile->timezoneOffset);

    //  or $offset < 0
    // $from = $today->addHours($profile->timezoneOffset);

    $to = $from->addDay();

        return $this->model
            ->where('xxx_profile_id', $profile->id)
            ->whereBetween(DB::raw('date(created_at)'), [$from, $to])
            ->orderBy('created_at', 'desc')
            ->limit(1)
            ->first($columns);
    }

Is this ok? Stupid? Is there a better way to handle that? If this is the good way, I need to update my migration and also store the offset ...

0 likes
18 replies
jimmck's avatar

Hi,

  1. You state you store everything in UTC timezone.
  2. How did you set your database dates in the database? Be totally specific. Done in PHP on server, done in database via defaults. Be specific.
  3. How you know the dates in your database are in UTC time.
  4. What is the column definition in the database schema, not your Eloquent Model, the database.
  5. Forget the time portion, let the database do the actual compares and date math. It will select and order them for you.
  6. If your are storing the dates in UTC, forget what the user's Timezone is except to know the Users date. You only need the date, dates time don't have timezones. Dates go from 0:00:00 to 23:59:59 hours, again the database can handle that.
jrean's avatar

@jimmck

2 - How did you set your database dates in the database? Be totally specific. Done in PHP on server, done in database via defaults. Be specific.

2a) Within config/app.php

    /*
    |--------------------------------------------------------------------------
    | Application Timezone
    |--------------------------------------------------------------------------
    |
    | Here you may specify the default timezone for your application, which
    | will be used by the PHP date and date-time functions. We have gone
    | ahead and set this to a sensible default for you out of the box.
    |
    */

    'timezone' => 'UTC',

3 - How you know the dates in your database are in UTC time.

3a) Because when I check the timestamp it reflects UTC time.

4 - What is the column definition in the database schema, not your Eloquent Model, the database.

4a) The definition proposed by Laravel with the created_at and updated_at timestamps

5 - Forget the time portion, let the database do the actual compares and date math. It will select and order them for you.

5a) Ok how?

6- If your are storing the dates in UTC, forget what the user's Timezone is except to know the Users date. You only need the date, dates time have timezones. Dates go from 0:00:00 to 23:59:59 hours, again the database can handle that.

6a) Ok! I'm using carbon everywhere already. What do you advise?

Thank you for your answer, I will appreciate more help :)

otepas's avatar

I have had similar issues with dates. I don't have a final solution yet, but my workaround could help you.

trait LocalizedDate

namespace App;

use Carbon\Carbon;


trait LocalizedDate
{
    /**
     * Timezone for front end representation
     * @var string
     */
    public $tz = 'Europe/Madrid';

    public $locale = 'es';

    /**
     * Creates a Carbon instance from the attribute value
     * taking into account the local timezone
     *
     * @param $value
     * @return Carbon
     */
    protected function asLocalizedDate($value)
    {
        return $this->asDateTime($value)->timezone($this->tz);
    }

    /**
     * Parses the given value to set an attribute and normalizes it
     * to the format used by the database
     *
     * @param $value
     * @return null|string
     */
    protected function parseLocalizedDate($value)
    {
        if ($value instanceof Carbon) {
            return $this->toUTCDateTimeString($value);
        }

        if ($value == null || $value == '') {
            return null;
        }

        if (preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $value)) {
            return $this->toUTCDateTimeString(
                Carbon::createFromFormat('d/m/Y', $value, $this->tz)
            );
        }

        if (preg_match('/^(\d{2})\/(\d{2})\/(\d{4})/', $value)) {
            return $this->toUTCDateTimeString(
                Carbon::createFromFormat('d/m/Y H:i', $value, $this->tz)
            );
        }

        if (preg_match('/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/', $value)) {
            return $this->toUTCDateTimeString(
                Carbon::createFromFormat('Y-m-d H:i|', $value, $this->tz)
            );
        }

        return $this->toUTCDateTimeString(
            Carbon::createFromFormat('Y-m-d H:i|+', $value, $this->tz)
        );
    }

    /**
     * Gives the date in string format for the DB format in UTC timezone
     *
     * @param Carbon $date
     * @return string
     */
    private function toUTCDateTimeString($date)
    {
        return $date->setTimezone('UTC')->toDateTimeString();
    }
}

And in the model where you have dates:

class Congress extends Model
{
    use LocalizedDate;

    /**
     * Returns Carbon instance of the start date attribute taking into account the timezone
     *
     * @param $value
     * @return \Carbon\Carbon
     */
    public function getStartAttribute($value)
    {
        return $this->asLocalizedDate($value);
    }

    /**
     * Returns Carbon instance of the end date attribute taking into account the timezone
     *
     * @param $value
     * @return \Carbon\Carbon
     */
    public function getEndAttribute($value)
    {
        return $this->asLocalizedDate($value);
    }

    /**
     * Sets the value of the start date attribute taking into account the timezone
     *
     * @param $value
     * @return \Carbon\Carbon
     */
    public function setStartAttribute($value)
    {
        $this->attributes['start'] = $this->parseLocalizedDate($value);
    }

    /**
     * Sets the value of the end date attribute taking into account the timezone
     *
     * @param $value
     * @return \Carbon\Carbon
     */
    public function setEndAttribute($value)
    {
        $this->attributes['end'] = $this->parseLocalizedDate($value);
    }
}

That way my dates have the correct timezone and when I compare dates Carbon takes the timezones into account.

And answering your question, yes, you have to use DateTime instead of just Date.

In my example I have hardcoded the timezone because it is the same for all the users. But it shouldn't be difficult to set it to whatever timezone a specific user has.

jrean's avatar

@otepas thank you but out of the box it may be complex to me :)

protected function asLocalizedDate($value)
    {
        return $this->asDateTime($value)->timezone($this->tz);
    }

Where is the asDateTime() signature?

otepas's avatar

@jrean asDateTime is a method of Eloquent Model

/**
     * Return a timestamp as DateTime object.
     *
     * @param  mixed  $value
     * @return \Carbon\Carbon
     */
    protected function asDateTime($value)
jimmck's avatar

@jrean Stop using created_at, always use updated_at. When a record is first added, created_at and updated_at are equal. For last profile at try the max of updated_at for that Date.

jrean's avatar

@jimmck I need to rely on created_at because the record can't be updated and even if it will be I still want to rely on the created_at.

@otepas thank you (I didn't know it) :)

jimmck's avatar

@jrean Fine if they are not updated they always will be equal. Using the laravel users table this query last registered user for a given date.

select max(updated_at) from laravel.users 
        where updated_at >= '2015-07-20 00:00:00' 
                and updated_at <= '2015-07-20 23:59:59'

Just plug in the date you want. No playing with time. Switch to min for the first Register.

jrean's avatar

Ok so the correct answer according to my first post seems to be:

For instance, to work on "today"

  • Define the start of the day for the user based on its timezone
$todayForUser = Carbon::today($user_timezone);
  • Convert the start of the day in UTC
$utcTodayStart = $todayForUser->copy()->timezone('UTC');
  • Convert the end of the day in UTC
$utcTodayEnd = $todayForUser->copy()->endOfDay()->timezone('UTC');

Or compacted:

        $utcTodayStart = Carbon::today($profile->timezone)->timezone('UTC');
        $utcTodayEnd   = Carbon::today($profile->timezone)->endOfDay()->timezone('UTC');

Then I can run my query on the appropriate range with:

->whereBetween('created_at', [$utcTodayStart, $utcTodayEnd])

:)

martinbean's avatar

@jrean I had this exact issue on a project last year. Users had accounts, and one of the things they could configure was timezone as the app was to be used by students in campuses across the world and in different timezones.

The way I solved it was with a trait that I applied to my models. On save, it would convert dates to UTC time so that they entered the database as UTC. On retrieval, they were converted to the user’s locale timezone (which would be the one they’d set in their account, fallback to the timezone sent by the user’s browser, before ending on the app.timezone configuration value.

1 like
jrean's avatar

@martinbean thank you :) Just to confirm, when you run some queries and you need to use date/datetime you take the user's date/datetime based on their timezone then convert to UTC before running the query, right?

The query should return object(s) with timestamps in UTC then on your views or whatever you use, you display the timestamps with the user's timezone?

martinbean's avatar

@jrean The user would submit dates and times in their local timezone. The trait would then convert that to UTC just before saving, so when it went in the database it went in at UTC time. Fetching records I’d just do the opposite: for any dates and times, detect the user’s timezone and convert from UTC to that timezone.

1 like
jrean's avatar

@martinbean last question.

The user would submit dates and times in their local timezone

Why you just didn't simply use the Application Timezone config option to "force" UTC? I mean, what was the reason of your choice?

    /*
    |--------------------------------------------------------------------------
    | Application Timezone
    |--------------------------------------------------------------------------
    |
    | Here you may specify the default timezone for your application, which
    | will be used by the PHP date and date-time functions. We have gone
    | ahead and set this to a sensible default for you out of the box.
    |
    */

    'timezone' => 'UTC',
martinbean's avatar

Why you just didn't simply use the Application Timezone config option to "force" UTC? I mean, what was the reason of your choice?

@jrean Because if a user submits a date-time of 2015-07-20 03:00:00, but they’re 8 hours ahead of UTC, then the date-time they’re submitting should be stored as 2015-07-19 19:00:00 in the database. When that date-time is retrieved, if the user’s timezone is still UTC+8, then it’ll be converted back.

jrean's avatar

@martinbean ok thank you! I have not enough experience (for now). I used the UTC in my timezone settings. So as you say when a user add a record (I don't care of its timezone, I just create the record in UTC). When I need to fetch records I use the Carbon::now($user_timezone) or Carbon::today($user_timezone) then I convert to ->timezone('UTC') before running the query. Then when I want to display data I convert (again) based on the user's timezone.

For me it works (until I discover I messed up :D)

muhammadnm's avatar

I'm having issue with Timezones too.. upto Lumen or Laravel models timezones are set to UTC (as in default configuration set, and I want that too)

But when record gets saved in database, it converts automatically to MySQL server's default timezone.

What should I do in this case?

muhammadnm's avatar

I've got that fixed.. I changed mysql field type from "timestamp" to "datetime" and Date/Times are saving in UTC (as per config)

jjudge's avatar

I realise this is old, but do watch out for statements like this:

$to = $from->addDay();

Carbon (for now) is not immutable and this statement will add a day to $from, then assign $to to the same value. The result is that both $from and $to will be a day later. Clone $from first to avoid this problem:

$to = $from->copy()->addDay();

Please or to participate in this conversation.