The App Uses UTC, People Use Thai Time — 7 Hours Off, No Errors
Time bugs scare me more than bugs that take a site down, because someone reports an outage within three minutes. A seven-hour time shift stays completely silent until someone happens to watch the screen when something is supposed to happen and finds that it does not.
Someone reported that they had scheduled an item to be published at three in the afternoon, but it actually appeared at ten at night. At first, I thought the queue was falling behind, so I checked the scheduler logs. Everything looked normal. The job was picked up at exactly the time recorded for it. No errors, no retries, not a single line showing anything wrong. What was wrong was that the recorded time was exactly seven hours off from what the person had entered.
Seven hours is the offset between Thai time and UTC, so that number immediately told me what kind of problem this was. But knowing it is a timezone issue and finding exactly where it goes wrong along the path are two different things, because a single path has four or five places where time can be converted, with some converting it correctly and others incorrectly.
I have replaced the feature, table, and column names in this article with fictional ones, but the sequence of bugs and the fixes are real.
Three points must agree, not just one
The first thing I did was stop thinking of this as a problem with the "system timezone" and break it down into three points to check separately.
The rule I have followed since this case is: what goes into the database, what comes back out, and what appears on screen must all agree. If I check just one point and call it a pass, I have checked nothing at all, because this type of bug comes from different parts interpreting the same value differently, rather than any one part being broken.
Setting it in env has no effect
The first thing most people try is changing the app timezone to Thai time and calling it done. That was not an option in this system, and trying it changed nothing anyway, because the configuration file had a hardcoded value instead of reading from env.
// config/app.php — ค่าตายตัว ใส่ APP_TIMEZONE ใน .env แล้วไม่มีผล
'timezone' => 'UTC',
// เขตเวลาสำหรับ "แสดงผล" แยกออกมาต่างหาก
'display_tz' => env('APP_DISPLAY_TZ', 'Asia/Bangkok'),
This is a very common misunderstanding: someone edits .env, runs config:cache, sees that nothing has changed, and concludes that the cache refuses to update. They then spend half a day investigating the cache, when the configuration file never called env() on that line in the first place.
# เช็คให้แน่ก่อนว่าค่าที่แอปใช้จริงคืออะไร อย่าเดาจากไฟล์ .env
php artisan tinker --execute='
echo "app.timezone = ", config("app.timezone"), PHP_EOL;
echo "display_tz = ", config("app.display_tz"), PHP_EOL;
echo "php ini = ", ini_get("date.timezone") ?: "(ว่าง)", PHP_EOL;
echo "now() = ", now()->toIso8601String(), PHP_EOL;
'
It looks like a one-line shortcut, but the result is that every value written to the database from then on will be Thai wall-clock time, while all existing values are still UTC. The same table now mixes two time standards with nothing to indicate which rows use which, making it much harder to fix than the original problem. Actually changing it requires a migration that converts all existing values together, and the system must be stopped while it runs.
The time input does not send a timezone
The second point is in the browser. A datetime-local input sends a plain wall-clock string such as 2026-10-21T15:00, with no timezone information at all. This is standard behavior, not a bug. The input type already has local in its name.
The problem is that PHP receives that string and has to guess which timezone the wall-clock time belongs to. Its default guess is the app timezone, which is UTC in this system.
// ผิด — Carbon ตีความ 15:00 เป็นหน้าปัด UTC ทั้งที่คนกรอกหมายถึงบ่ายสามบ้านเรา
$at = Carbon::parse($request->input('publish_at'));
// ถูก — บอกให้ชัดว่าหน้าปัดนี้เป็นของเขตเวลาไหน
$at = Carbon::parse($request->input('publish_at'), config('app.display_tz'));
The difference between these two lines is exactly seven hours, and nothing flags it, because both return objects of the correct type. No exception, no warning.
// เทียบให้เห็นกับตา
$s = '2026-10-21T15:00';
echo Carbon::parse($s)->toIso8601String(), PHP_EOL;
// 2026-10-21T15:00:00+00:00 <- บ่ายสามที่ลอนดอน = สี่ทุ่มบ้านเรา
echo Carbon::parse($s, 'Asia/Bangkok')->toIso8601String(), PHP_EOL;
// 2026-10-21T15:00:00+07:00 <- บ่ายสามบ้านเรา ตามที่คนกรอกตั้งใจ
The line that writes Thai wall-clock time into a UTC column
The third point was the most painful, and it was what let this case survive several rounds of checks.
Suppose I have fixed the second point correctly and now have a time object with a +07:00 timezone attached. If I put that object into the database directly through the query builder, what gets written is Thai wall-clock time, not UTC, because converting it to a string only formats the numbers as they stand. It does not convert the timezone.
$at = Carbon::parse('2026-10-21T15:00', 'Asia/Bangkok'); // 15:00 +07:00
// ผิด — ตัวสร้างคำสั่งเขียน "2026-10-21 15:00:00" ลงคอลัมน์ที่ทุกคนอ่านเป็น UTC
DB::table('posts')->where('id', $id)->update(['publish_at' => $at]);
// ถูก — ปรับเป็นเวลาสากลก่อนเสมอ
DB::table('posts')->where('id', $id)->update(['publish_at' => $at->utc()]);
Converts it if a cast is declared
A model with casts declaring this column as datetime has a conversion layer on writes and reads, so values stored in the database are always UTC. But the cast must actually be declared, not just assumed to exist.
No conversion layer at all
It formats the value as a string and sends it directly. The result is the object's wall-clock time, so if that is Thai time, it writes Thai wall-clock time into a column that everything else in the system reads as UTC.
This is why the same system had a mix of correct and incorrect paths. Every path that wrote through a model was correct. But a path someone had written using the query builder to update several rows at once for speed was off. It was also used less often, so nobody noticed.
# ไล่หาทุกจุดที่เขียนคอลัมน์เวลาผ่านตัวสร้างคำสั่ง
grep -rnE "DB::table\(.*\)->(update|insert)" app/ \
| grep -E "_at|_time|date"
# และไล่หาโมเดลที่ยังไม่ได้ประกาศ cast ให้คอลัมน์เวลาของตัวเอง
grep -rn "publish_at" app/Models/
Warning signs I should have caught sooner
Looking back, there were already two signs, but nobody connected them at the time.
The first was that the confirmation message after saving and the time shown on the list page did not match. The confirmation message is usually built from an in-memory variable that still has the correct timezone, while the list page reads the value back from the database. Whenever these differ, it means the database write did something to the value along the way.
The second was that the time shown on the list page always differed from the entered time by a fixed amount, regardless of the value entered. Such an exact, constant difference points straight to a timezone issue, not a calculation issue.
One layer makes timezone decisions
After fixing it, I did not want this to happen again, so I put all timezone decisions in one place and required everyone to go through it for conversions.
namespace App\Support;
use Carbon\CarbonImmutable;
final class Clock
{
/** รับหน้าปัดไทยจากฟอร์ม คืนค่าที่พร้อมเก็บลงฐาน (UTC เสมอ) */
public static function fromForm(?string $wall): ?CarbonImmutable
{
if ($wall === null || trim($wall) === '') {
return null;
}
return CarbonImmutable::parse($wall, config('app.display_tz'))->utc();
}
/** รับค่าจากฐาน คืนหน้าปัดไทยสำหรับเอาไปโชว์ */
public static function toDisplay(?CarbonImmutable $utc): ?CarbonImmutable
{
return $utc?->setTimezone(config('app.display_tz'));
}
/** ค่าที่เอาไปใส่ค่าเริ่มต้นให้ช่องกรอก — ต้องเป็นหน้าปัดไทยไม่มี tz */
public static function toFormValue(?CarbonImmutable $utc): string
{
return self::toDisplay($utc)?->format('Y-m-d\TH:i') ?? '';
}
}
I then required every path to use only these three functions. The benefit is that an audit no longer means tracing the entire project. I only need to check whether anyone calls Carbon::parse or setTimezone outside this file.
grep -rn "Carbon::parse\|CarbonImmutable::parse\|setTimezone" app/ resources/views/ \
| grep -v "app/Support/Clock.php"
# ผลลัพธ์ที่ต้องการคือว่างเปล่า
Tests that must fail for the right reason
I learned this one the hard way: I wrote tests and they all went green, even though the system was still wrong, because the tests used the same faulty time conversion logic. The results were wrong in exactly the same way, so they passed.
The fix is for tests to assert against the actual raw values in the database, not values read through the app's own conversion layer.
public function test_publish_at_is_stored_as_utc(): void
{
config(['app.display_tz' => 'Asia/Bangkok']);
$this->post('/posts', [
'title' => 'ทดสอบ',
'publish_at' => '2026-10-21T15:00',
])->assertRedirect();
// อ่านค่าดิบ ไม่ผ่าน Eloquent ไม่ผ่าน cast ไม่ผ่าน Clock
$raw = DB::selectOne('select publish_at from posts order by id desc limit 1');
$this->assertSame('2026-10-21 08:00:00', $raw->publish_at);
// แล้วค่อยเช็คว่าอ่านกลับมาโชว์แล้วได้หน้าปัดไทยเดิม
$post = Post::latest('id')->first();
$this->assertSame('2026-10-21 15:00', Clock::toDisplay($post->publish_at)->format('Y-m-d H:i'));
}
The test above checks all three points I mentioned at the start: the stored value is UTC, the value read back converts correctly to Thai wall-clock time, and that wall-clock time matches what was entered. If I were to write just one time-related test in a project, this would be it. It catches all three types of bugs described here, and it fails immediately if someone changes the app timezone without converting the existing data.
The checklist I use
- First, establish which timezone the database time columns are agreed to use, then document it in the table documentation instead of leaving everyone to guess.
- Check the timezone the app actually uses by reading it from the app itself, not from the env file.
- Check every point that accepts time input from users. Always specify the wall-clock timezone when parsing; never leave it to guesswork.
- Check every point that writes time columns through the query builder. Convert to UTC before every write.
- Compare the confirmation message after saving with the value shown on the list page. They must match; a difference means there is still a gap.
- Write a test that reads raw values from the database, then confirm that it actually fails when utc is removed. A test I have never seen go red is a test I cannot trust.
Conclusion
Time bugs do not come from someone writing bad code. They come from different parts of the system having different agreements about what a set of numbers means, with nobody writing that agreement down anywhere. Everyone had been guessing correctly because the frequently used paths happened to handle the conversion already, until someone wrote a new path that bypassed that conversion layer.
What I did after this case was therefore more than fixing the faulty line. I documented in the table documentation that every time column uses UTC and consolidated conversion into one layer. One test handles the rest, keeping watch. Storing Thai time throughout the system is also a valid choice. There is nothing wrong with it; just choose one approach and document it clearly. Do not leave two standards in the same table. If I have left anything out or made any typos, I will come back and correct them later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first