Eloquent 的 get 和 set 访问器,说到底,它们就是来做字段转换的,别指望它们能撑起一个游戏状态机。正确的做法,是引入自定义 Cast 类,搭配 GameState 枚举或类,把状态逻辑封装起来,然后用 start()、end() 这样的显式方法去控制流转,而不是直接赋值。这样才能保证校验、事件和审计都到位。

记住,Eloquent 的 get* 和 set* 访问器,它们只负责字段转换,不触发状态校验、不维护流转约束、也不记录变更历史。所以,别指望它们能存游戏状态机。
用 cast + 自定义 Cast 类处理游戏状态值
直接把 game_state 字段设为字符串或整型,再靠访问器硬编码状态逻辑,很快会失控。那么,正确的做法是什么?让 Eloquent 知道这个字段“是个状态对象”,而不是一个普通的字符串或整数。
- 首先,定义一个
GameState枚举类(PHP 8.1+ 首选)或者一个经典 class,把所有的合法状态和流转规则都封装在里面。 - 然后,在模型里通过
$casts = ['game_state' => GameStateCast::class]声明类型转换。 - 最后,
GameStateCast这个类要实现Illuminate\Contracts\Database\Eloquent\CastsAttributes接口。它的get方法返回GameState实例,set方法只接受合法实例,并存储其底层值,比如'waiting'或1。
这样一来,$game->game_state->isWaiting() 这样的调用就变得可读性很强,而 $game->game_state = GameState::playing() 这样的赋值也完全可控。数据库里存的,还是那个原始值,干净利落。
状态变更必须走显式方法,禁止直接赋值 game_state
允许用户或代码直接写 $game->game_state = 'ended',这就像打开潘多拉魔盒,会绕过所有业务约束——比如,没检查是否已结算、没通知玩家、没更新分数表。所以,必须定义显式方法。
- 在模型里定义
start()、pause()、end()等方法,每个方法内部都要做状态合法性判断。例如,只有waiting状态才能start。 - 这些方法内部应该调用
$this->setAttribute('game_state', ...),而不是直接赋值属性,这样才能确保set*访问器和 cast 仍然生效。 - 同时,配合
static::updating()观察器,在保存前进行二次校验:如果新旧状态不合法,比如从ended跳回playing,就抛出InvalidArgumentException。 - 别忘了在方法里触发事件,比如
GameStateChanged::dispatch($this, $old, $new),让积分、推送、日志等解耦逻辑能够响应。
避免在访问器里查数据库或改状态
这个很常见。有人在 getIsPlayingAttribute() 里,去查关联的 Player 表,判断是否全员就位——这会导致 N+1 查询问题。更糟的是,有人在 setGameStateAttribute() 里自动调用 $this->end(),让赋值行为变得不可预测。
get*访问器只能基于当前模型已加载的属性做计算,不能触发额外查询,也不能修改其他字段。- 如果需要一个复合判断,比如“是否可开始”,应该定义为普通方法
canStart(): bool,由调用方明确决定何时执行。 - 状态变更逻辑必须集中、显式、可测试——全部塞进访问器等于把状态机藏进语法糖里,调试时根本找不到入口。
说到底,真正难的不是怎么让状态看起来像属性,而是怎么让每次状态跳转都留下痕迹、可回滚、可审计。Eloquent 属性机制只是数据管道,状态流转规则得靠领域模型自己守住边界。