PHP枚举状态机实战——用强类型终结散落各处的if-else状态判断

2026-07-26 0 111

做业务开发,状态流转是绕不开的话题。订单、工单、审批流、用户认证,处处都有“状态”的影子。前些年我接手过一个电商系统的订单模块,打开代码一看,订单状态全是字符串——'pending''paid''shipped''completed'到处散落,伴随它们的是一大堆if ($status == 'paid')switch ($status)的判断,还夹杂着各种strtolower的防御性调用以防有人写成了'Paid'。修改一个状态逻辑,要在七八个文件里改条件判断。这种代码跑了一两年没出大问题,但每次维护都提心吊胆。

PHP 8.1引入的枚举,给我们提供了一个全新的思路。枚举不只是把一堆常量收拢到一个类里,更关键的是它支持方法定义和接口实现。用枚举来建模状态,再配合状态机的约束规则,可以把原本松散的状态逻辑收拢到一个高度内聚的模块里,外部的业务代码变得异常干净。这篇文章就讲我怎么用PHP枚举从零搭起一个强类型状态机,并且分享在实际项目里落地的几个具体案例。

一、从字符串常量到枚举——第一步的改进

在PHP 8.1之前,管理状态的最常见做法是定义一组类常量:

class OrderStatus {
    const PENDING = 'pending';
    const PAID = 'paid';
    const SHIPPED = 'shipped';
    const COMPLETED = 'completed';
    const CANCELLED = 'cancelled';
}

然后到处用OrderStatus::PENDING。这样做的好处是避免了字符串拼写错误,但有一个致命缺陷:这些常量本质上仍然是字符串,方法签名里只能写string $status,无法限制调用方只能传入几个有效值。如果你在一个函数里期望接收一个订单状态,IDE也没法帮你列出所有合法选项,只能看文档。

换成枚举就能解决这个类型约束的问题:

enum OrderStatus: string {
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Completed = 'completed';
    case Cancelled = 'cancelled';
}

现在你可以用OrderStatus做类型声明了。任何一个接收订单状态的方法,只要写上OrderStatus $status,调用时就只能传入枚举实例,传字符串会直接报TypeError。光是这一点,就省去了多少在函数开头做if (!in_array($status, validStatuses))的防御代码。

function updateOrderStatus(int $orderId, OrderStatus $newStatus): void {
    // 不再需要校验类型,枚举已经保证了$newStatus一定是有效状态
    // 执行业务逻辑...
}

二、在枚举里定义状态流转规则

类型安全只是开胃菜,枚举真正的威力在于可以把行为也封装进去。一个状态机核心要管两件事:当前状态下允许哪些操作,以及操作后迁移到什么新状态。这些规则能不能直接写在状态枚举里?当然可以。

我们来设计一个更完整的订单状态机。不是所有状态之间都能随意跳转,比如已完成的订单就不能变成待支付,已取消的订单也不能再发货。每个状态应该自己知道它可以迁移到哪些其他状态。

enum OrderState: string {
    case Draft = 'draft';
    case Pending = 'pending';
    case Confirmed = 'confirmed';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';
    case Refunded = 'refunded';

    /**
     * 返回从当前状态可以合法迁移到的下一个状态列表
     * @return OrderState[]
     */
    public function allowedTransitions(): array {
        return match($this) {
            self::Draft => [self::Pending, self::Cancelled],
            self::Pending => [self::Confirmed, self::Cancelled],
            self::Confirmed => [self::Shipped, self::Cancelled],
            self::Shipped => [self::Delivered],
            self::Delivered => [self::Refunded],
            self::Cancelled => [],
            self::Refunded => [],
        };
    }

    /**
     * 判断是否允许迁移到指定状态
     */
    public function canTransitionTo(OrderState $target): bool {
        return in_array($target, $this->allowedTransitions(), true);
    }
}

match表达式把状态流转图直接写成代码,清晰得像看一张状态转移表。任何一个开发人员点开这个枚举,几秒钟就能看懂订单状态的完整生命周期。以后要加一个新状态,也只需要在这个文件里改动match的一个分支,影响范围一目了然。

接着写一个服务类来处理状态迁移的动作,它会调用枚举的canTransitionTo来做前置校验:

class OrderStateMachine {
    /**
     * @throws InvalidStateTransitionException
     */
    public function transition(Order $order, OrderState $targetState): void {
        $currentState = $order->getState();

        if (!$currentState->canTransitionTo($targetState)) {
            throw new InvalidStateTransitionException(
                sprintf(
                    '不允许从 "%s" 迁移到 "%s"',
                    $currentState->value,
                    $targetState->value
                )
            );
        }

        // 执行实际的状态更新(例如数据库更新)
        $order->setState($targetState);

        // 可以在这里触发领域事件,如 OrderConfirmedEvent
    }
}

外部业务代码变得非常简单:

$stateMachine = new OrderStateMachine();
try {
    $stateMachine->transition($order, OrderState::Confirmed);
} catch (InvalidStateTransitionException $e) {
    // 处理非法状态迁移,比如返回给前端错误提示
    throw new BusinessException($e->getMessage());
}

这样一来,状态流转规则不再散落在各个控制器或Service的杂七杂八判断里,而是全部收拢到了OrderState枚举和OrderStateMachine类中。整个模块的职责清晰到让人舒适。

三、给状态加上行为——执行进入状态时的副作用

很多时候,状态迁移不仅仅是改一个字段值,还需要执行一些额外操作。比如订单变成“已发货”时,需要写一条物流记录并给用户发通知;变成“已取消”时,需要释放库存。这些行为如果也写在状态机里,代码会更加内聚。

枚举支持实现接口,我们可以定义一个StateBehaviour接口,让每个状态实现自己的进入逻辑:

interface OnEnterState {
    public function onEnter(Order $order): void;
}

enum OrderState: string implements OnEnterState {
    // ... 枚举成员定义

    case Shipped = 'shipped';
    case Cancelled = 'cancelled';
    // ...

    public function onEnter(Order $order): void {
        match($this) {
            self::Shipped => $this->handleShipped($order),
            self::Cancelled => $this->handleCancelled($order),
            default => null, // 多数状态不需要特殊副作用
        };
    }

    private function handleShipped(Order $order): void {
        // 记录物流信息
        ShipmentLog::create([
            'order_id' => $order->id,
            'status' => 'shipped',
            'shipped_at' => now(),
        ]);
        // 发送通知
        event(new OrderShipped($order));
    }

    private function handleCancelled(Order $order): void {
        // 释放库存
        InventoryService::release($order->items);
        // 发送取消通知
        event(new OrderCancelled($order));
    }
}

然后在OrderStateMachine里,状态更新成功后调用$targetState->onEnter($order)。这样状态相关的副作用也收拢到状态枚举自身里了。改动某个状态的进入行为,只需要修改对应的handleXxx私有方法,不用跨文件搜索。

有人可能会担心,枚举里调用外部的服务和事件会不会让枚举变得太重?这确实是一个需要权衡的点。我的做法是,如果副作用比较简单(例如只发一个事件),放在枚举里没问题;如果涉及复杂的跨服务调用,则保持OrderStateMachine的纯粹性,把副作用放在监听器或专门的Service里处理。两种方式可以并存,只要团队对边界有一个共识。

四、更复杂的场景——带条件的状态迁移

有些状态迁移不仅取决于当前状态,还取决于订单的某些属性。比如,已支付的订单只有金额大于零才能进入待发货状态(金额为零可能是赠品订单,直接变成已完成),或者已发货的订单只有签收时间超过7天才能变成已完成。这些条件该怎么融入枚举状态机?

可以在allowedTransitions中不仅返回目标状态列表,还返回一个可选的检查条件。一种做法是让每个状态提供一个检查函数,但枚举方法本身无法额外传递参数(比如订单对象),这时需要把判断逻辑提升到OrderStateMachine中,但依然依赖枚举提供的元数据。

我们可以在枚举里定义一个guardClosure,返回一组条件检查:

enum OrderState: string {
    // ...

    /**
     * 返回迁移的目标状态及对应的守卫条件(如果有)
     * @return array<OrderState, ?Closure(Order): bool>
     */
    public function transitions(): array {
        return match($this) {
            self::Confirmed => [
                self::Shipped => fn(Order $order) => $order->total_amount > 0,
                self::Cancelled => null, // 无额外条件
            ],
            self::Shipped => [
                self::Delivered => fn(Order $order) =>
                    $order->shipped_at && $order->shipped_at->diffInDays(now()) >= 7,
            ],
            // 其他状态...
            default => [],
        };
    }
}

然后在OrderStateMachine里使用:

public function transition(Order $order, OrderState $target): void {
    $transitions = $order->getState()->transitions();
    if (!array_key_exists($target, $transitions)) {
        throw new InvalidStateTransitionException('非法状态迁移');
    }
    $guard = $transitions[$target];
    if ($guard !== null && !$guard($order)) {
        throw new InvalidStateTransitionException('不满足迁移条件');
    }
    // 更新状态并执行onEnter...
}

这样,带条件的迁移也整合进了状态机里。条件的闭包写在枚举里,但执行时接受订单实例,既利用了枚举的聚合优势,又保留了灵活性。

五、实际项目中的落地经验

将这套枚举状态机方案应用到真实项目后,有几个体会特别深。

1. 状态的名字尽量贴近业务术语

枚举成员的命名和字符串值最好用领域专家的用语,比如Confirmed而不是Payed,因为不同业务里“支付”可能不是一个独立状态。直接和产品经理对齐状态名,减少翻译成本。

2. 始终通过状态机来修改状态

全局约定“订单状态只能通过OrderStateMachine来修改”,不允许任何地方直接调用$order->setState()。这要依靠代码审查和团队规范来保障。一旦出现绕过状态机的直接赋值,整个状态流转的一致性就会被破坏。

3. 数据库字段存储枚举值

数据库status字段仍然存储字符串(如confirmed),读出来后在模型层转换为枚举实例。可以在Eloquent模型里做属性转换:

protected $casts = [
    'status' => OrderState::class,
];

这样从数据库读出的字符串会自动变成OrderState实例,写回时也会自动转为字符串。全程类型安全。

4. 处理废弃的状态值

需求变更后某些状态可能不再使用,但数据库里还存在旧数据。枚举中的废弃case可以保留,但需要在allowedTransitions里返回空数组,并标记为@deprecated。数据修缮脚本可以在时机成熟时统一迁移遗留数据。

六、总结

用PHP枚举搭建状态机,本质上是在用一种更严格、更聚合的方式表达业务规则。枚举的类型安全性消灭了低级错误,match表达式的引入让状态流转图直接可读,而方法封装让状态相关的行为不再四分五裂。这个方案并不复杂,也不需要引入第三方库,PHP 8.1本身已经提供了足够的语法糖。

如果你的项目还在用字符串或常量数组管理状态,不妨挑一个中等复杂度的模块先尝试重构一下。把状态值换成枚举,把流转规则写进match,你会立刻感到代码的清晰度提升了一个档次。而且这种清晰度不是依赖于注释和文档,而是由解释器强制保障的——这才是最让人安心的地方。

PHP枚举状态机实战——用强类型终结散落各处的if-else状态判断
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 php PHP枚举状态机实战——用强类型终结散落各处的if-else状态判断 https://www.taomawang.com/server/php/2421.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务