Laravel: Forced to deal with Time Zones
UTC will always be your friend, unless you discard him
The golden rule for handling timestamps in Laravel is always treat them in UTC, and this is for a simple reason: it’s the ground truth for time.
Most applications won’t ever had to deal with using any time zone, as most of the heavy lifting will be done on the frontend and the controllers. Time zones are presentational, and with little JavaScript effort you can show the local time and send a request back to your app in UTC.
The problem comes when you need to save information which is not in UTC, like Chilean UF.
UF changes every day, every Chilean day
In Chile, the UF (“Unidad de Fomento”) is a value that represents the inflation on common products. It’s another economic index for the country, and is used for reference for some transactions, like those regarding insurance costs or real state. You can see the UF for each day until the next month at the SII website (the Chilean IRS).
There are some API and web crawlers that will help you retrieve the UF value for a given day or period of time. In my case, I had to save it into the database to avoid throttling the API, and that’s when the problem started.
The UF value changes daily, but not at 00:00 UTC, but on Continental Chile time — the country has three time zones but this is the main one for al legalities. The exact time it changes varies between UTC -3 and UTC -4 (Daylight Savings), as Chile is behind UTC.
For example, in Chile is Saturday 23:00 PM, but it’s Sunday 02:00 AM in UTC, which is one hour short for the next UF value to take effect. Six months later, the same hour is 03:00 AM UTC.
To avoid the shenanigans of dealing with shifting time zones and what not, the solution was simple: leverage Carbon time zone handling and PHP time zone data to store the dates into the database as UTC, instead of just the date.
Anyway, here is a snippet of the command I use to retrieve all the UF available from the API, and save each of the days returned inside the database.
use App\Models\Uf;
use App\Http\Clients\UfApi;
use Illuminate\Database\Eloquent\Collection;
/**
* Handle the command execution.
*
* @return void
*/
public function handle(UfApi $api)
{
$uf = Collection::make($api->between('today', 'next month'))
->mapInto(Uf::class)
->each->save();
$this->line("Saved {$uf->count()} UF records in the database");
}
The above code does something really simple. Let’s start from the first lines.
The first step is to retrieve the UF from the API. Behind the scenes, it will shift the date to the America/Santiago
time zone, so when we say “today” we’re saying today at local Chile time, not UTC. This way the API receives the proper date.
use Illuminate\Support\Carbon;
/**
* Returns an array of al UF between the given dates, inclusive.
*
* @return array<valid_at: int, value: float>[]
*/
public function getFrom(mixed $start, mixed $end): array
{
$date = Carbon::parse($date, 'America/Santiago');
// Call the API and parse the results into an array...
}
After that, we create an Eloquent Collection and map each item into a Model instance. When the time is saved, it's shifted to the Chilean time zone into UTC through the shiftTimezone()
method inside an Attribute Mutator, but parse()
will also work the same if we use the second parameter.
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @property-read \Illuminate\Support\Carbon $valid_at
*/
class Uf extends Model
{
/**
* Set the day from a date in America/Santiago time zone.
*/
protected function validAt(): Attribute
{
return Attribute::set(
fn ($value) => Carbon::parse($date, 'America/Santiago');
});
}
}
The real moment the day changes it’s at 03:00:00
in UTC for this particular day, so this is what we want to save in the database, as using setTimezone()
won’t work.
use Illuminate\Support\Carbon;
$tz = 'America/Santiago';
$date = Carbon::parse('2025-04-01')->toImmutable();
echo $date->utc(); // 2025-05-01 00:00:00
echo $date->setTimezone($tz)->utc(); // 2025-05-01 00:00:00
echo $date->shiftTimezone($tz)->utc(); // 2025-05-01 03:00:00
Once it’s saved, we no longer need to make arrangements elsewhere.
Finding the UF for the day as UTC
One neat thing about Laravel’s Eloquent ORM it's that, by default, it will always save the dates as UTC. We don’t need to make any conversion unless we’re not using UTC in our application or Model configuration.
This means that, to find a record for a specific date, we require the date to also be UTC. The easiest way to do it is to pick the latest record that is equal or below the current moment.
In the following example, if it’s 2025-05-01 02:30:00,
then it will find the UF at 2025-04-30 04:00:00
, as it’s the latest that doesn’t exceed the given datetime.
use App\Models\Uf;
// 2025-05-01 02:30:00 UTC
$now = now();
// 2025-04-30 03:00:00 UTC
$uf = Uf::where('valid_at', '<=', now())->latest('day')->first();
This can also work to get a group of UF between given datetimes.
use App\Models\Uf;
$start = now()->subMonth();
$end = now()->addMonth();
$manyUf = Uf::whereColumnBetween('valid_at', [$start, $end])->get();
Another alternative is to always force the date to be exactly when the Chilean day starts. I prefer to use a local scope that takes care of moving the time and shifting the time zone on the incoming date, so it matches perfectly to what is in the database, which is faster than ordering since it’s a simple index match — assuming the valid_at
column is an index on the database.
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Support\Carbon;
#[Scope]
protected function forDay(Builder $query, mixed $date = 'today')
{
$date = Carbon::parse($date, 'America/Santiago')->startOfDay()->utc();
return $query->where('valid_at', $date)->first();
}
Considering the inconsistencies of various database engines handling column timestamps with zones, it strongly recommended to always use UTC for all purposes. If you can’t, then you will probably need a secondary column to add the difference in hours or the name of the time zone to shift the timestamp.
protected function validAt()
{
return Attribute::get(
fn($value, $attrs) => Carbon::parse($value, $attrs['tz']);
);
}
You never know if down the road you need to upgrade or change databases in your application, and with that the time zone behavior.