Site logo
Tác giả
  • avatar Nguyễn Đức Xinh
    Name
    Nguyễn Đức Xinh
    Twitter
Ngày xuất bản
Ngày xuất bản

DateTime và Timestamp Trong Laravel

Đọc lý thuyết về TIMESTAMPDATETIME 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ả TIMESTAMPDATETIME

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_atTIMESTAMP, mang tính chất một Instant — một thời điểm thực tế, timezone-aware khi đọc/ghi. delivery_atDATETIME, 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 TIMESTAMPDATETIME 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_atdelivery_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

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_atJST:

2026-09-18 15:00 JST  =  2026-09-18T06:00:00Z

Nhưng nếu quy ước delivery_atUTC:

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:

DATETIME không chứa timezone information. Khi Carbon setTimezone() 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 $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.