宁觅波 发表于 2025-12-17 08:55:01

PHP 值对象实战指南:避免原始类型偏执

PHP 值对象实战指南:避免原始类型偏执

上一篇文章里,我们聊了原始类型偏执(Primitive Obsession)在 PHP 里为什么这么常见:邮箱、金额、日期、ID……统统用 string/int/float/array 传来传去。领域含义被抹平,校验逻辑散落在各处,代码越写越难改。
这一篇我们继续往下走:值对象(Value Object)不仅能让代码更清晰,还能让协作、测试和后续演进都更省心。不管你用的是 Laravel、Symfony 还是别的框架,只要项目里有明确的领域概念,值对象都能派上用场。
原文链接 PHP 值对象实战指南:避免原始类型偏执
1. 重复出现的模式:不只是代码味道

在 PHP 项目里,某些规则会反复出现。日期就是典型例子:
public function registerEvent(string $eventDate, string $timeZone): void
{
    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $eventDate)) {
      throw new InvalidArgumentException('Invalid date format.');
    }
    if (!in_array($timeZone, DateTimeZone::listIdentifiers())) {
      throw new InvalidArgumentException('Invalid time zone.');
    }
    // More logic here...
}这里的日期和时区都用 string 表示。问题在于:一旦日期格式要改、或者时区规则有变化,你就会开始在各个角落复制粘贴同样的验证逻辑——漏一处就出事故。
2. 值对象登场:Date 和 TimeZone

与其在每个入口都手写校验,不如把“日期”“时区”做成值对象,让它们自己保证合法性。
2.1 Date 值对象

final class Date
{
    private string $value;

    private function __construct(string $date)
    {
      if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
            throw new InvalidArgumentException('Invalid date format.');
      }
      $this->value = $date;
    }

    public static function fromString(string $date): self
    {
      return new self($date);
    }

    public function value(): string
    {
      return $this->value;
    }

    public function __toString(): string
    {
      return $this->value;
    }
}2.2 TimeZone 值对象

final class TimeZone
{
    private string $value;

    private function __construct(string $timeZone)
    {
      if (!in_array($timeZone, DateTimeZone::listIdentifiers())) {
            throw new InvalidArgumentException('Invalid time zone.');
      }
      $this->value = $timeZone;
    }

    public static function fromString(string $timeZone): self
    {
      return new self($timeZone);
    }

    public function value(): string
    {
      return $this->value;
    }

    public function __toString(): string
    {
      return $this->value;
    }
}2.3 在业务里怎么用

public function registerEvent(Date $eventDate, TimeZone $timeZone): void
{
    // No need for repetitive validation
    // Logic continues...
}参数一眼就能看懂,而且验证逻辑只存在一份:在值对象里。
3. 给值对象加上行为

值对象不只是“更强的类型”。它还能承载和这个概念紧密相关的行为。
比如你需要判断活动日期是否在未来,可以把逻辑放进 Date:
final class Date
{
    // ..

    public function isInTheFuture(): bool
    {
      $now = new DateTime();
      $eventDate = new DateTime($this->value);
      return $eventDate > $now;
    }
}这样就不用在每个用到日期的地方都重复写一遍比较逻辑,也更符合领域表达:判断未来与否,本来就是“日期”这个概念的一部分。
4. 金额:用值对象守住精度

处理金额是原始类型偏执最容易踩坑的地方之一。用 float 表示钱,舍入误差迟早会找上门;再加上币种、汇率,复杂度会迅速拉高。
把金额做成值对象,通常的做法是:

[*]用最小单位(比如分)把金额存成 int
[*]币种作为字段和金额绑定在一起
下面这个 Money 示例进一步加上了换汇的行为:
final class Money
{
    private int $amount; // Stored in minor units (e.g., cents)
    private string $currency;

    // Constructor and other methods...

    public function convertToCurrency(string $targetCurrency, float $exchangeRate): self
    {
      $convertedAmount = (int) round($this->amount * $exchangeRate);
      return new self($convertedAmount, $targetCurrency);
    }
}把规则关在 Money 里,你的业务代码就不用到处关心“这里是分还是元”“币种对不对”“舍入怎么做”。
5. 在框架里落地

一旦你开始用值对象,Laravel / Symfony 反而会更好用:你能把“原始数据 ↔ 值对象”的转换放到框架扩展点里,业务层拿到的就都是领域类型。
5.1 Laravel 示例

Laravel 里可以用自定义 cast,把数据库字段自动转成值对象:
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class MoneyCast implements CastsAttributes
{
    public function get($model, string $key, $value, array $attributes)
    {
      return Money::fromInt($value, 'USD');
    }

    public function set($model, string $key, $value, array $attributes)
    {
      return $value instanceof Money ? $value->amount() : $value;
    }
}把这个 cast 挂到 Eloquent 模型上后,取出来的就是 Money,而不是裸值,代码会干净很多。
5.2 Symfony 示例

Symfony 里可以用 Doctrine 的 embeddables 或自定义 DBAL type,把 Money / EmailAddress 这类复杂类型映射到数据库:
class MoneyType extends \Doctrine\DBAL\Types\Type
{
    const MONEY = 'money'; // Custom type name

    public function convertToPHPValue($value, \Doctrine\DBAL\Platforms\AbstractPlatform $platform)
    {
      return Money::fromInt($value, 'USD');
    }

    public function convertToDatabaseValue($value, \Doctrine\DBAL\Platforms\AbstractPlatform $platform)
    {
      return $value instanceof Money ? $value->amount() : $value;
    }
}这样做的好处是:从数据库到领域层,你始终在用“领域类型”,而不是一堆无意义的 string/int/float。
6. 怎么迁移现有代码库

引入值对象不需要推倒重来。最稳妥的方式是渐进式迁移:

[*]先改边界层:在 HTTP controller、表单请求、CLI 命令里,把输入的原始值解析成值对象
[*]再改服务层:逐步把 service 方法签名从原始类型换成值对象
[*]配合静态分析:用 PHPStan 或 Psalm 强化类型约束,尽早发现不匹配
7. 结语:让领域自己说话

值对象的意义,不在于“OO 更纯粹”,而在于让领域概念变得清楚、可约束、可复用。
下次你准备在方法里传一堆 string/int 的时候,不妨停一下问自己:
“这真的是一个简单值吗?还是一个应该被命名、被约束的领域概念?”
做出这个小改变,短期能减少重复校验和隐性 bug;长期则会让整个代码库更稳、更好改——也更照顾未来维护它的你。

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

蚬蕞遂 发表于 2026-1-4 15:33:49

鼓励转贴优秀软件安全工具和文档!

丝甲坞 发表于 2026-1-13 18:33:53

鼓励转贴优秀软件安全工具和文档!

焦听云 发表于 2026-1-14 00:01:01

这个有用。

酝垓 发表于 2026-1-14 19:15:08

感谢分享,学习下。

供挂 发表于 2026-1-14 22:23:22

东西不错很实用谢谢分享

战匈琼 发表于 2026-1-15 14:11:26

鼓励转贴优秀软件安全工具和文档!

裴涛 发表于 2026-1-18 09:06:26

新版吗?好像是停更了吧。

诈知 发表于 2026-1-21 10:54:36

收藏一下   不知道什么时候能用到

表弊捞 发表于 2026-1-23 05:35:36

热心回复!

圄旧剖 发表于 2026-1-23 06:38:40

懂技术并乐意极积无私分享的人越来越少。珍惜

奸轲嫣 发表于 2026-1-25 10:42:24

yyds。多谢分享

越蔓蔓 发表于 2026-1-28 05:19:21

yyds。多谢分享

玻倌瞽 发表于 2026-1-30 23:35:41

鼓励转贴优秀软件安全工具和文档!

倡遍竽 发表于 2026-2-4 09:00:32

yyds。多谢分享

欧阳雪枫 发表于 2026-2-4 10:52:39

不错,里面软件多更新就更好了

喳谍 发表于 2026-2-6 09:38:06

热心回复!

泠邸 发表于 2026-2-8 02:19:00

感谢发布原创作品,程序园因你更精彩

卜笑 发表于 2026-2-8 11:18:32

前排留名,哈哈哈

汪之亦 发表于 2026-2-9 01:16:45

分享、互助 让互联网精神温暖你我
页: [1] 2
查看完整版本: PHP 值对象实战指南:避免原始类型偏执