- Tác giả

- Name
- Nguyễn Đức Xinh
- Ngày xuất bản
- Ngày xuất bản
DateTime và Timestamp Trong Laravel
Đọc lý thuyết về TIMESTAMP và DATETIME là một chuyện, nhưng tận mắt thấy hai kiểu cột này cư xử khác nhau như thế nào khi đi qua Laravel Eloquent — từ lúc insert bằng SQL thô, đến lúc Model trả nó ra JSON — lại là một trải nghiệm rất khác. Bài lab này dựng một bảng orders tối giản với đúng một cột TIMESTAMP và một cột DATETIME, rồi lần lượt quan sát: dữ liệu trong DB, giá trị Eloquent attribute khi có và không có $casts, và cuối cùng là format JSON mà Frontend thực sự nhận được.
Mục tiêu không phải để chứng minh cột nào "tốt hơn", mà để thấy rõ bằng thực nghiệm: $casts = ['field' => 'datetime'] thay đổi điều gì, và quan trọng hơn — nó không thay đổi điều gì. Đây chính là điểm rất nhiều Developer hiểu nhầm khi debug bug timezone trong thực tế.
Yêu Cầu
Để làm theo lab này, cần có:
- MySQL/MariaDB với quyền tạo database, table.
- Một project Laravel bất kỳ (đã có thể chạy
php artisan tinker). - Laravel Eloquent cơ bản: Model,
$casts, Controller, Route.
Bước 1: Tạo Database Và Kiểm Tra Timezone
Trước khi tạo bảng, tạo một database riêng cho lab và xác nhận session đang ở timezone nào — đây là bước không nên bỏ qua vì toàn bộ kết quả bên dưới phụ thuộc vào timezone của session tại thời điểm insert:
CREATE DATABASE timezone_demo
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE timezone_demo;
SELECT
@@global.time_zone AS global_timezone,
@@session.time_zone AS session_timezone,
NOW() AS now_value,
UTC_TIMESTAMP() AS utc_now;
Trong lab này, giả sử database session timezone là UTC. Nếu chưa đúng, set lại session hiện tại:
SET time_zone = '+00:00';
Bước 2: Tạo Table Với Cả TIMESTAMP Và DATETIME
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
delivery_at DATETIME NULL
);
Ý nghĩa của từng cột trong bảng này: created_at là TIMESTAMP, mang tính chất một Instant — một thời điểm thực tế, timezone-aware khi đọc/ghi. delivery_at là DATETIME, chỉ đơn thuần lưu Date + Time dưới dạng literal, không mang theo bất kỳ thông tin timezone nào.
Bước 3: Insert Dữ Liệu Test
Insert 3 record, mỗi record một mốc thời gian khác nhau để dễ quan sát:
-- Record 1: 2026-09-18 06:00:00 UTC = 15:00 JST = 13:00 ICT
INSERT INTO orders (created_at, delivery_at)
VALUES ('2026-09-18 06:00:00', '2026-09-18 15:00:00');
-- Record 2
INSERT INTO orders (created_at, delivery_at)
VALUES ('2026-09-18 10:00:00', '2026-09-19 10:00:00');
-- Record 3
INSERT INTO orders (created_at, delivery_at)
VALUES ('2026-09-19 01:00:00', '2026-09-20 09:30:00');
Kiểm tra lại dữ liệu vừa insert:
SELECT id, created_at, delivery_at FROM orders ORDER BY id;
+----+---------------------+---------------------+
| id | created_at | delivery_at |
+----+---------------------+---------------------+
| 1 | 2026-09-18 06:00:00 | 2026-09-18 15:00:00 |
| 2 | 2026-09-18 10:00:00 | 2026-09-19 10:00:00 |
| 3 | 2026-09-19 01:00:00 | 2026-09-20 09:30:00 |
+----+---------------------+---------------------+
Ở bước này, cả hai cột nhìn giống hệt nhau khi query trực tiếp bằng SQL — sự khác biệt giữa TIMESTAMP và DATETIME chưa lộ ra ở đây, mà sẽ lộ ra ngay khi dữ liệu này đi qua Eloquent.
Bước 4: Tạo Laravel Model — Chưa Dùng $casts
php artisan make:model Order
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected $table = 'orders';
public $timestamps = false;
}
Model này cố tình chưa khai báo $casts, để quan sát hành vi mặc định của Eloquent trước.
Controller và route tối giản để expose dữ liệu qua API:
<?php
namespace App\Http\Controllers;
use App\Models\Order;
class OrderController extends Controller
{
public function index()
{
return Order::query()->get();
}
}
use App\Http\Controllers\OrderController;
Route::get('/orders', [OrderController::class, 'index']);
Gọi GET /api/orders, response nhận được:
[
{
"id": 1,
"created_at": "2026-09-18 06:00:00",
"delivery_at": "2026-09-18 15:00:00"
},
{
"id": 2,
"created_at": "2026-09-18 10:00:00",
"delivery_at": "2026-09-19 10:00:00"
},
{
"id": 3,
"created_at": "2026-09-19 01:00:00",
"delivery_at": "2026-09-20 09:30:00"
}
]
Khi chưa có $casts, cả created_at và delivery_at đều chỉ là string trong tay Eloquent — Laravel chưa biến chúng thành Carbon object, nên API response giữ nguyên format thô của DB (YYYY-MM-DD HH:mm:ss), không có bất kỳ thông tin timezone nào đi kèm.
Bước 5: Thêm $casts Và Quan Sát Sự Khác Biệt
Sửa lại Model, thêm $casts:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected $table = 'orders';
public $timestamps = false;
protected $casts = [
'created_at' => 'datetime',
'delivery_at' => 'datetime',
];
}
Bây giờ, cả $order->created_at lẫn $order->delivery_at đều là instance của Carbon\Carbon thay vì string. Controller không cần đổi gì, gọi lại GET /api/orders:
[
{
"id": 1,
"created_at": "2026-09-18T06:00:00.000000Z",
"delivery_at": "2026-09-18T15:00:00.000000Z"
},
{
"id": 2,
"created_at": "2026-09-18T10:00:00.000000Z",
"delivery_at": "2026-09-19T10:00:00.000000Z"
},
{
"id": 3,
"created_at": "2026-09-19T01:00:00.000000Z",
"delivery_at": "2026-09-20T09:30:00.000000Z"
}
]
Khác biệt rõ rệt nhất: "2026-09-18 06:00:00" (không $casts) trở thành "2026-09-18T06:00:00.000000Z" (có $casts) — Laravel tự động serialize Carbon instance sang chuẩn ISO 8601 khi convert Model sang JSON, kèm hậu tố Z biểu thị UTC, dù giá trị gốc trong delivery_at (DATETIME) thực chất không hề mang timezone.
Đây cũng chính là lý do vì sao trong Controller ở trên (return Order::query()->get();), không cần gọi toIso8601String() một cách thủ công: $casts đã lo phần "chuyển field thành Carbon instance", còn khi Laravel convert Model (hoặc bất kỳ giá trị nào chứa Carbon instance bên trong) sang JSON — dù trả trực tiếp Eloquent Model hay qua response()->json($order) — nó tự động serialize Carbon thành date string theo chuẩn ISO 8601. toIso8601String() (và các biến thể như toISOString()) chỉ thực sự cần thiết khi muốn chủ động lấy ra một chuỗi string để dùng cho việc khác ngoài JSON response — ví dụ: gán vào biến để ghi log, nhét vào payload gửi sang service/API khác, so sánh chuỗi, hay build một chuỗi hiển thị tuỳ biến. Nếu chỉ đơn giản trả Model/attribute ra JSON, cứ để Laravel tự lo phần serialize, không cần gọi toIso8601String() thêm.
Bước 6: Xác Nhận Bằng Tinker — string vs Carbon
Kiểm tra trực tiếp trong Tinker để thấy rõ kiểu dữ liệu thực sự thay đổi:
php artisan tinker
Tạm bỏ $casts để kiểm tra hành vi mặc định:
$order = App\Models\Order::first();
gettype($order->created_at);
// "string"
Thêm lại $casts rồi kiểm tra lại:
$order = App\Models\Order::first();
get_class($order->created_at);
// "Illuminate\Support\Carbon"
$order->created_at->toIso8601String();
// "2026-09-18T06:00:00+00:00"
$order->created_at->toISOString();
// "2026-09-18T06:00:00.000000Z"
Bước 7: Convert Sang Timezone Khác Với Carbon
Vì created_at giờ đã là Carbon object, có thể convert sang bất kỳ timezone nào để hiển thị, mà không làm thay đổi Instant gốc:
config('app.timezone');
// "UTC"
now()->timezoneName;
// "UTC"
$order = App\Models\Order::first();
$order->created_at->setTimezone('Asia/Tokyo')->toIso8601String();
// "2026-09-18T15:00:00+09:00"
$order->created_at->setTimezone('Asia/Ho_Chi_Minh')->toIso8601String();
// "2026-09-18T13:00:00+07:00"
Nếu muốn API trả DateTime theo timezone của người dùng thay vì mặc định UTC, có thể convert ngay trong Controller trước khi trả response:
public function show(Order $order)
{
$timezone = 'Asia/Tokyo';
return response()->json([
'id' => $order->id,
'created_at' => $order->created_at->setTimezone($timezone)->toIso8601String(),
'delivery_at' => $order->delivery_at->setTimezone($timezone)->toIso8601String(),
]);
}
{
"id": 1,
"created_at": "2026-09-18T15:00:00+09:00",
"delivery_at": "2026-09-18T15:00:00+09:00"
}
Đây chính là điểm cần dừng lại quan sát kỹ: created_at (TIMESTAMP, UTC thật) convert sang JST cho ra 15:00 — hợp lý. Nhưng delivery_at (DATETIME, giá trị literal 15:00:00 không mang timezone) sau khi Carbon "convert" cũng ra 15:00:00+09:00 — một kết quả trùng hợp gây hiểu lầm nguy hiểm, được phân tích ở bước tiếp theo.
Bước 8: Cạm Bẫy Của DATETIME — Không Mang Theo Timezone
Trong ví dụ trên, cột delivery_at DATETIME đang chứa giá trị literal 2026-09-18 15:00:00. Vấn đề là: giá trị này có nghĩa gì còn tuỳ vào quy ước mà application chọn, và MySQL không hề biết quy ước đó.
Nếu quy ước delivery_at là JST:
2026-09-18 15:00 JST = 2026-09-18T06:00:00Z
Nhưng nếu quy ước delivery_at là UTC:
2026-09-18 15:00 UTC = 2026-09-19T00:00 JST
Hai quy ước cho ra hai thời điểm thực tế chênh nhau tới 9 tiếng, trong khi Database lưu y hệt một chuỗi ký tự. Đây chính là nội dung cốt lõi của câu nói quen thuộc:
DATETIMEkhông chứa timezone information. Khi CarbonsetTimezone()lên một giá trịDATETIME, nó chỉ đang gán tạm một timezone label rồi tính toán lại — chứ không hề "biết" giá trị gốc thực sự thuộc timezone nào. Nếu quy ước application sai ngay từ đầu, mọi phép convert phía sau đều sai theo, mà không có bất kỳ warning hay exception nào cảnh báo.
Bước 9: Hai Cách Thiết Kế Đúng Cho delivery_at
Từ cạm bẫy ở trên, có hai hướng thiết kế rõ ràng, tùy vào bản chất nghiệp vụ của delivery_at:
Cách 1 — Nếu delivery_at là một Instant (thời điểm giao hàng thực tế): nên lưu dưới dạng UTC (tương tự created_at), để có thể convert an toàn sang bất kỳ timezone nào khi hiển thị.
delivery_at (UTC) = 2026-09-18 06:00:00
{ "delivery_at": "2026-09-18T06:00:00Z" }
FE ở Nhật hiển thị 2026-09-18 15:00, FE ở Việt Nam hiển thị 2026-09-18 13:00 — cùng một Instant, khác cách hiển thị, không có gì mơ hồ.
Cách 2 — Nếu delivery_at là một Business Local DateTime (ví dụ: "giao hàng tại Nhật vào 15:00 ngày 18/09", gắn cứng với cửa hàng ở Nhật bất kể ai xem):
delivery_at = 2026-09-18 15:00:00
timezone = Asia/Tokyo (quy ước cố định của hệ thống, hoặc lưu kèm một cột riêng)
Điều quan trọng nhất không nằm ở việc chọn cách 1 hay cách 2, mà là: quy ước đó phải được xác định rõ ràng và ghi lại, không được để mặc định ngầm hoặc suy đoán từ tên cột.
Tổng Kết Lab
MySQL
│
┌────────────┴────────────┐
│ │
TIMESTAMP DATETIME
│ │
Instant / UTC Date + Time
│ No timezone
│ │
└────────────┬────────────┘
│
▼
Eloquent Model
│
$casts = datetime
│
▼
Carbon
│
▼
JSON Serialization
│
▼
ISO 8601 / UTC
│
▼
React / FE
│
┌────────┴────────┐
▼ ▼
Japan Vietnam
UTC+9 UTC+7
Không $casts |
Có $casts |
|
|---|---|---|
| Eloquent attribute | string |
Carbon |
| DB value | 2026-09-18 06:00:00 |
2026-09-18 06:00:00 |
| JSON serialization | Giữ nguyên string kiểu DB | ISO 8601 |
| Timezone conversion | Không tự có | Có thể thao tác bằng Carbon (setTimezone()) |
| FE nhận | YYYY-MM-DD HH:mm:ss |
ISO 8601 |
Kết luận quan trọng nhất của lab này: $casts = ['field' => 'datetime'] không có nghĩa là Laravel tự động "biết timezone nghiệp vụ" của một cột DATETIME. Nó chỉ biến giá trị thành một Carbon object để dễ thao tác và serialize — còn timezone thực sự và ý nghĩa nghiệp vụ của giá trị đó vẫn phải được thiết kế rõ ràng ở tầng application, không thể phó mặc cho $casts tự suy luận giúp.
