Một khách hàng nhắn vô: đơn hàng của họ nằm ở trạng thái "đang xử lý" chín ngày rồi. Mình lần ra một queue job ghi status = 'canceled', trong khi toàn bộ phần còn lại của app so sánh với 'cancelled'. Listener hoàn tiền không chạy, filter trên dashboard không khớp, và hổng ai phát hiện vì hai chuỗi đó đều là PHP hợp lệ. Grep thử thì thấy mấy chuỗi status nằm rải rác hơn 40 file.
Enum có sẵn trong PHP từ bản 8.1 và nó xử lý đúng loại bug này. Có điều nhiều team chỉ xài nửa vời: khai báo case Paid = 'paid' xong vẫn để business rule trong mấy câu if rải khắp codebase. Bài này đi xa hơn một chút: backed enum làm nguồn sự thật duy nhất, đặt hành vi ngay trên enum, interface và constant, parse an toàn với tryFrom(), Eloquent cast, và cách gỡ magic string ra khỏi code mà không làm hư data production.
Pure enum và backed enum
Enum là một tập giá trị đóng, có tên. PHP có hai loại:
enum Suit { case Hearts; case Spades; } // pure: no scalar value
// backed: every case maps to a unique int or string you can persist
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
}
echo OrderStatus::Paid->name; // "Paid" (the identifier in code)
echo OrderStatus::Paid->value; // "paid" (what goes into the database)
var_dump(OrderStatus::Paid === OrderStatus::from('paid')); // bool(true): cases are singletons
Pure enum chỉ có case, ngoài ra không có gì. Xài nó cho mấy trạng thái không bao giờ rời khỏi process: mode của parser, biến thể UI, chọn strategy. Backed enum gắn cho mỗi case một giá trị scalar (int hoặc string, không có loại nào khác), phải unique và là hằng. Giá trị đó là thứ mình lưu vô database, đưa vô JSON, và nhận về từ form.
Vài điều nên biết trước khi xây trên nó:
->namelà tên định danh mình viết trong code.->valuelà giá trị được lưu. Đổi tên case là refactor; đổi value là data migration.- Mỗi case là một object singleton, nên
===chạy đúng và đó là phép so sánh nên xài. - Backed enum implement
BackedEnum(kế thừaUnitEnum); pure enum chỉ implementUnitEnum. Code generic có thể type-hint theo hai interface này. json_encode()biến backed case thành value của nó. Pure case thì hổng serialize ra JSON được: cóJSON_THROW_ON_ERRORlà ănJsonExceptionbáo non-backed enum không có cách serialize mặc định.
Nên chọn string: status = 3 trong câu query lúc support thì chẳng nói lên điều gì.
Đặt hành vi lên enum: method và match
Lợi ích thật sự tới khi mấy rule thuộc về một status nằm ngay cạnh nó. Enum có method (instance lẫn static), và match ($this) giúp viết rất gọn:
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Pending => 'Waiting for payment',
self::Paid => 'Paid',
self::Shipped => 'On the way',
self::Delivered => 'Delivered',
self::Cancelled => 'Cancelled',
};
}
public function isFinal(): bool
{
return $this === self::Delivered || $this === self::Cancelled;
}
/** @return list<self> */
public function allowedTransitions(): array
{
return match ($this) {
self::Pending => [self::Paid, self::Cancelled],
self::Paid => [self::Shipped, self::Cancelled],
self::Shipped => [self::Delivered],
self::Delivered, self::Cancelled => [],
};
}
public function canTransitionTo(self $next): bool
{
return in_array($next, $this->allowedTransitions(), true);
}
}
Có hai chi tiết làm cách này chắc chắn. Thứ nhất, mấy biểu thức match không có nhánh default. Nếu ai đó thêm case Refunded = 'refunded' mà quên xử lý, PHP ném UnhandledMatchError ngay lần đầu code đó chạy, và test loop qua cases() bắt được trước khi lên production. Có nhánh default thì đơn hoàn tiền sẽ âm thầm nhận sai label.
Thứ hai, bảng transition giờ chỉ có đúng một chỗ. Controller hỏi $order->status->canTransitionTo(OrderStatus::Shipped) chứ không tự viết lại in_array($order->status, ['paid']). Khi bên product đổi rule, mình sửa một method và mấy test bao nó.
Còn enum không làm được gì: giữ state. Enum không được khai báo property, kể cả static property, và case không tạo bằng new được.
Constant, static method và interface
Enum có thể implement interface, xài trait (miễn trait không khai báo property), khai báo constant và có static method. Nhờ vậy nó gắn vô code generic rất tự nhiên:
interface HasLabel
{
public function label(): string;
}
trait ListsValues
{
/** @return list<string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
enum PaymentMethod: string implements HasLabel
{
use ListsValues;
case Card = 'card';
case BankTransfer = 'bank_transfer';
case Wallet = 'wallet';
public const DEFAULT = self::Card;
public const DEFAULT_VALUE = self::Card->value; // allowed since PHP 8.2
public function label(): string
{
return match ($this) {
self::Card => 'Credit / debit card',
self::BankTransfer => 'Bank transfer',
self::Wallet => 'E-wallet',
};
}
}
echo implode(',', PaymentMethod::values()); // card,bank_transfer,wallet
HasLabel giúp một component form render bất kỳ enum nào có label. Trait ListsValues bỏ được một dòng mà nếu không có thì mình sẽ copy vô từng enum. Constant tiện cho giá trị mặc định: PaymentMethod::DEFAULT là một case thật, nên type-safe ở mọi chỗ xài nó. Từ PHP 8.2 mình còn đọc được ->value trong constant expression, tiện cho tham số attribute và default của migration.
from() và tryFrom(): parse input không tin cậy
Backed enum có sẵn hai cách để biến scalar ngược lại thành case:
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Cancelled = 'cancelled';
}
// from(): use when an invalid value is a bug. Throws ValueError.
$status = OrderStatus::from('paid');
// tryFrom(): use at the boundary, where bad input is expected. Returns null.
function statusFromWebhook(array $payload): OrderStatus
{
$raw = $payload['status'] ?? null;
if (! is_string($raw)) {
throw new InvalidArgumentException('Webhook payload has no status.');
}
// Normalise known aliases first.
$normalised = match (strtolower(trim($raw))) {
'canceled' => 'cancelled',
default => strtolower(trim($raw)),
};
return OrderStatus::tryFrom($normalised)
?? throw new InvalidArgumentException("Unknown order status [{$raw}].");
}
echo statusFromWebhook(['status' => ' Canceled '])->name; // Cancelled
Quy tắc đơn giản:
from()khi giá trị sai nghĩa là hệ thống của mình có bug: giá trị chính mình ghi, key trong config, cột mình kiểm soát. Nó némValueError, và mình muốn lỗi đó thật ồn.tryFrom()ở rìa hệ thống: webhook, import CSV, query string, bất cứ thứ gì bên thứ ba gửi tới. Nó trảnull, và kiểu viết?? throwbiến null đó thành một exception của domain với message dễ hiểu.
Coi chừng kiểu dữ liệu khi có declare(strict_types=1): from('1') trên enum backed bằng int sẽ ném TypeError chứ không phải ValueError, vì tham số bắt buộc là int. Ép kiểu ở rìa ((int) $request->input('level')) sau khi đã validate là số.
Eloquent enum cast trong Laravel 13
Laravel cast thẳng một cột thành backed enum. Đọc ra là case, ghi vô nhận case (hoặc value của nó), và query builder bind case thành giá trị scalar:
namespace App\Models;
use App\Enums\OrderStatus;
use App\Enums\PaymentMethod;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
use Illuminate\Database\Eloquent\Model;
final class Order extends Model
{
protected $fillable = ['status', 'payment_method', 'allowed_methods'];
protected function casts(): array
{
return [
'status' => OrderStatus::class,
'payment_method' => PaymentMethod::class,
'allowed_methods' => AsEnumCollection::of(PaymentMethod::class),
];
}
public function markAsPaid(): void
{
if (! $this->status->canTransitionTo(OrderStatus::Paid)) {
throw new \DomainException("Cannot pay an order that is {$this->status->value}.");
}
$this->status = OrderStatus::Paid;
$this->save();
}
public function scopeOpen(Builder $query): void
{
// The query builder binds BackedEnum cases as their ->value.
$query->whereIn('status', [OrderStatus::Pending, OrderStatus::Paid, OrderStatus::Shipped]);
}
}
AsEnumCollection::of() lưu một mảng JSON các value và hydrate thành collection các case, hợp với mấy cột kiểu "các phương thức thanh toán được phép cho từng merchant". Với input, Rule::enum() kiểm tra theo các case, only() / except() thu hẹp lại, và $request->enum('status', OrderStatus::class) trả về case (hoặc null):
namespace App\Http\Requests;
use App\Enums\OrderStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class UpdateOrderStatusRequest extends FormRequest
{
public function rules(): array
{
return [
'status' => ['required', Rule::enum(OrderStatus::class)->only([OrderStatus::Shipped, OrderStatus::Cancelled])],
];
}}
Route binding cũng hiểu backed enum: type-hint OrderStatus $status là /orders/status/bay-ba tự trả 404, khỏi viết dòng nào.
Còn cột trong database, cứ để string thường (ví dụ varchar(32)) với ->default(OrderStatus::Pending->value), đừng xài kiểu ENUM của database. Vậy thì thêm case không cần đổi schema, và enum PHP vẫn là nguồn sự thật duy nhất.
Gỡ magic string mà không làm hư production
Chuyển qua enum là một chuỗi bước chứ không làm một phát xong luôn, vì cast sẽ ném lỗi ngay ở giá trị đầu tiên nó không map được:
- Grep các chuỗi, kể cả trong view, JavaScript và report SQL. Đây là checklist của mình.
- Kiểm tra database. Trước khi viết cast, coi thử thật sự trong DB đang lưu gì. Data cũ lúc nào cũng có bất ngờ:
-- Run this BEFORE adding the cast. Every row must map to a case.
SELECT status, COUNT(*) AS total
FROM orders
GROUP BY status
ORDER BY total DESC;
- Tạo enum với đúng các value database chứa sau khi dọn.
- Thêm cast. Mọi phép so sánh
$order->status === 'paid'giờ âm thầm thànhfalse; sửa luôn trong cùng pull request dựa vô danh sách grep. - Validate input bằng
Rule::enum()để giá trị bậy không lọt vô được nữa. - Dời logic vô enum: transition, label, màu, "trạng thái này kết thúc chưa".
- Xóa constant cũ (
Order::STATUS_PAIDvà đồng bọn) khi không còn chỗ nào tham chiếu.
Bước 4 và 6 có thể chia ra nhiều lần deploy; riêng việc kiểm tra DB phải xong trước khi cast lên production.
Kiểm thử enum với Pest
Logic của enum là thuần, nên test chạy nhanh và hổng cần setup gì. Test bảng transition bằng dataset, loop qua cases() để case mới không lọt lưới, và thêm một chốt chặn ở mức database:
use App\Enums\OrderStatus;
use Illuminate\Support\Facades\DB;
it('allows only the documented transitions', function (OrderStatus $from, OrderStatus $to, bool $allowed) {
expect($from->canTransitionTo($to))->toBe($allowed);
})->with([
'pay a pending order' => [OrderStatus::Pending, OrderStatus::Paid, true],
'ship a paid order' => [OrderStatus::Paid, OrderStatus::Shipped, true],
'ship an unpaid order' => [OrderStatus::Pending, OrderStatus::Shipped, false],
'revive a cancelled order' => [OrderStatus::Cancelled, OrderStatus::Pending, false],
]);
it('gives every case a label', function () {
foreach (OrderStatus::cases() as $case) {
expect($case->label())->toBeString()->not->toBeEmpty();
}
});
it('has no status values in the database that the enum cannot read', function () {
$unknown = DB::table('orders')
->distinct()
->pluck('status')
->reject(fn (string $value): bool => OrderStatus::tryFrom($value) !== null);
expect($unknown)->toBeEmpty();
});
Chạy test cuối trên một bản snapshot staging là biết cast có ném lỗi với data thật hay không. Còn vòng loop cases() bắt được case mới thiếu label.
Những lỗi hay gặp
So sánh attribute đã cast với chuỗi. Sau khi thêm cast, $order->status === 'paid' luôn ra false vì mình đang so object với string. Không có lỗi gì hết, chỉ là chạy sai. So với case: $order->status === OrderStatus::Paid.
Xài case làm key của mảng. Enum là object, nên [Plan::Free => 10] ném TypeError. Xài $limits[Plan::Pro->value], hoặc WeakMap khi thật sự cần chính case đó làm key.
Có nhánh default trong match. Nó phá mất cơ chế kiểm tra đủ case, thứ làm enum an toàn hơn chuỗi. Bỏ nó đi, trừ khi "mọi trường hợp còn lại" là một business rule thật.
Row không hợp lệ làm crash lúc đọc. Nếu database chứa giá trị enum không biết, đọc attribute sẽ ném ValueError. Chuyện này xảy ra lúc load trang và trong queue worker, chứ không chỉ ở chỗ code đã ghi giá trị sai. Dọn data trước, rồi mới cast.
Đổi value của case tùy tiện. Value nằm trong row, cache, job trong queue và client gọi API. Đối xử với nó như đổi tên cột: thêm value mới, migrate data, rồi mới bỏ value cũ.
Khi nào nên xài enum, khi nào không
Xài backed enum khi tập giá trị đóng và do code của mình quản lý: trạng thái đơn hàng, role có permission viết cứng, phương thức thanh toán mình thật sự tích hợp, log level, gói tính năng. Thêm case là một thay đổi code phải qua review, và đó đúng là điều mình muốn với những giá trị có gắn logic.
Xài pure enum cho mấy lựa chọn nội bộ không bao giờ lưu xuống, như chiều sắp xếp.
Đừng xài enum khi danh sách là data chứ không phải code. Danh mục sản phẩm do admin tạo, status ticket riêng cho từng tenant, quốc gia có metadata chỉnh sửa được: mấy cái đó nên nằm trong bảng có foreign key. Nếu thêm một giá trị mà không nên phải deploy, thì đó không phải enum.
Mục tiêu rất đơn giản: chỗ nào quan tâm tới status thì hỏi enum, và database chỉ được chứa những giá trị enum đọc được.
Thử thách
Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.
Đề bài
Viết một state machine cho đơn hàng bằng backed enum. OrderStatus (PHP: enum backed bằng string; Python: StrEnum) có các case Pending/PENDING='pending', Paid/PAID='paid', Shipped/SHIPPED='shipped', Delivered/DELIVERED='delivered', Cancelled/CANCELLED='cancelled'. isFinal() / is_final() trả true chỉ với Delivered và Cancelled. canTransitionTo(next) / can_transition_to(nxt) chỉ cho phép: Pending qua Paid hoặc Cancelled, Paid qua Shipped hoặc Cancelled, Shipped qua Delivered; mọi trường hợp khác (kể cả chuyển về chính nó và mọi chuyển từ trạng thái cuối) trả false. Static parse(raw) / classmethod parse(raw) trim khoảng trắng, chuyển chữ thường, map alias 'canceled' thành 'cancelled' rồi trả về case tương ứng; giá trị lạ thì ném InvalidArgumentException (PHP) hoặc raise ValueError (Python). Hàm transition(current, next) parse giá trị next, ném DomainException (PHP) hoặc raise InvalidTransition (Python) khi không được chuyển, ngược lại trả về status mới.
Code khởi tạo
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
public function isFinal(): bool
{
// TODO: Delivered and Cancelled are final, everything else is not
throw new LogicException('TODO');
}
public function canTransitionTo(self $next): bool
{
// TODO: Pending -> Paid|Cancelled, Paid -> Shipped|Cancelled, Shipped -> Delivered
// TODO: final states allow nothing; a status never transitions to itself
throw new LogicException('TODO');
}
public static function parse(string $raw): self
{
// TODO: trim + lowercase, map the alias 'canceled' to 'cancelled',
// then use tryFrom(); throw InvalidArgumentException for unknown values
throw new LogicException('TODO');
}
}
function transition(OrderStatus $current, string $next): OrderStatus
{
// TODO: parse $next, throw DomainException if the move is not allowed,
// otherwise return the new status
throw new LogicException('TODO');
}
from enum import StrEnum
class InvalidTransition(Exception):
pass
class OrderStatus(StrEnum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
def is_final(self) -> bool:
# TODO: DELIVERED and CANCELLED are final, everything else is not
raise NotImplementedError
def can_transition_to(self, nxt: "OrderStatus") -> bool:
# TODO: PENDING -> PAID|CANCELLED, PAID -> SHIPPED|CANCELLED, SHIPPED -> DELIVERED
# TODO: final states allow nothing; a status never transitions to itself
raise NotImplementedError
@classmethod
def parse(cls, raw: str) -> "OrderStatus":
# TODO: strip + lowercase, map the alias "canceled" to "cancelled",
# raise ValueError for unknown values
raise NotImplementedError
def transition(current: OrderStatus, nxt: str) -> OrderStatus:
# TODO: parse nxt, raise InvalidTransition if the move is not allowed,
# otherwise return the new status
raise NotImplementedError
Lời giải của bạn
Gợi ý
Đã mở hết gợi ýGom bảng transition vô một biểu thức match ($this) (hoặc một dict bên Python) không có nhánh default, để case mới không bị bỏ sót
Chuẩn hóa trước (trim, chữ thường, alias), rồi gọi tryFrom() và biến null thành exception bằng ?? throw
transition() nên xài lại parse() và canTransitionTo() chứ đừng viết lại rule lần nữa
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
public function isFinal(): bool
{
return $this === self::Delivered || $this === self::Cancelled;
}
public function canTransitionTo(self $next): bool
{
$allowed = match ($this) {
self::Pending => [self::Paid, self::Cancelled],
self::Paid => [self::Shipped, self::Cancelled],
self::Shipped => [self::Delivered],
self::Delivered, self::Cancelled => [],
};
return in_array($next, $allowed, true);
}
public static function parse(string $raw): self
{
$value = strtolower(trim($raw));
if ($value === 'canceled') {
$value = 'cancelled';
}
return self::tryFrom($value)
?? throw new InvalidArgumentException("Unknown order status [{$raw}].");
}
}
function transition(OrderStatus $current, string $next): OrderStatus
{
$target = OrderStatus::parse($next);
if (! $current->canTransitionTo($target)) {
throw new DomainException("Cannot move from {$current->value} to {$target->value}.");
}
return $target;
}
from enum import StrEnum
class InvalidTransition(Exception):
pass
class OrderStatus(StrEnum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
def is_final(self) -> bool:
return self in (OrderStatus.DELIVERED, OrderStatus.CANCELLED)
def can_transition_to(self, nxt: "OrderStatus") -> bool:
allowed = {
OrderStatus.PENDING: {OrderStatus.PAID, OrderStatus.CANCELLED},
OrderStatus.PAID: {OrderStatus.SHIPPED, OrderStatus.CANCELLED},
OrderStatus.SHIPPED: {OrderStatus.DELIVERED},
OrderStatus.DELIVERED: set(),
OrderStatus.CANCELLED: set(),
}
return nxt in allowed[self]
@classmethod
def parse(cls, raw: str) -> "OrderStatus":
value = raw.strip().lower()
if value == "canceled":
value = "cancelled"
try:
return cls(value)
except ValueError:
raise ValueError(f"Unknown order status [{raw}]") from None
def transition(current: OrderStatus, nxt: str) -> OrderStatus:
target = OrderStatus.parse(nxt)
if not current.can_transition_to(target):
raise InvalidTransition(f"Cannot move from {current} to {target}")
return target

