Trung bình PHP

PHP Enum nâng cao: backed enum, method, tryFrom và Eloquent cast thay cho magic string

Chỉ một lỗi gõ 'canceled' với 'cancelled' cũng đủ làm đơn hàng kẹt nhiều ngày. Xài backed enum có method, interface, tryFrom và Eloquent cast để dẹp magic string.

29 Th10, 2026 15 phút Cập nhật 23 Th10, 2026 8 Khối code
Sơ đồ
graph LR A[Grep các magic string] --> B[Kiểm tra giá trị trong DB] B --> C[Tạo backed enum] C --> D[Thêm Eloquent cast] D --> E[Validate input bằng Rule enum] E --> F[Dời logic vô method của enum] F --> G[Xóa các hằng chuỗi cũ]

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

  • ->name là tên định danh mình viết trong code. ->value là 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ừa UnitEnum); pure enum chỉ implement UnitEnum. 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_ERROR là ăn JsonException bá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ém ValueError, 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 ?? throw biế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:

graph LR A[Grep các magic string] --> B[Kiểm tra giá trị trong DB] B --> C[Tạo backed enum] C --> D[Thêm Eloquent cast] D --> E[Validate input bằng Rule enum] E --> F[Dời logic vô method của enum] F --> G[Xóa các hằng chuỗi cũ]
  1. Grep các chuỗi, kể cả trong view, JavaScript và report SQL. Đây là checklist của mình.
  2. 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;
  1. Tạo enum với đúng các value database chứa sau khi dọn.
  2. Thêm cast. Mọi phép so sánh $order->status === 'paid' giờ âm thầm thành false; sửa luôn trong cùng pull request dựa vô danh sách grep.
  3. Validate input bằng Rule::enum() để giá trị bậy không lọt vô được nữa.
  4. Dời logic vô enum: transition, label, màu, "trạng thái này kết thúc chưa".
  5. Xóa constant cũ (Order::STATUS_PAID và đồ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 tương tá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

php
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');
}
python
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 ý
Gợi ý 1

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

Gợi ý 2

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

Gợi ý 3

transition() nên xài lại parse() và canTransitionTo() chứ đừng viết lại rule lần nữa

Lời giải · php
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;
}
Lời giải · python
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

Cùng chủ đề PHP