万物皆字符串 PHP 中的原始类型偏执
PHP 让你能快速交付功能。
需要邮箱?用字符串。 需要价格?float 凑合用。 需要用户数据?随便往数组里塞。
感觉很快------直到出问题。
你开始看到这样的函数:
php
function registerUser(
string $email,
string $password,
string $firstName,
string $lastName,
string $countryCode
) {
// ...
}
或者这样的 service 方法:
php
public function createDiscount(
string $code,
float $amount,
string $currency,
string $type,
int $expiresAtTimestamp
): void {
// ...
}
全是原始类型:string、int、float、array。 你的领域概念不知不觉变成了"只是数据"。
这就是原始类型偏执(Primitive Obsession):
用原始类型(string、int、array、float)来表示那些本该有自己类型的领域概念。
本文讨论:
- 原始类型偏执在 PHP 中的表现(尤其是数组和弱类型值)
- 为什么它让代码更难改、更容易出问题
- 如何一步步重构到值对象
- 具体例子:EmailAddress、Money、CouponCode、类型化 ID、集合
- 如何与 Laravel 和 Symfony 等现代 PHP 框架配合
避免原始类型偏执不是为了当"OO 纯粹主义者",而是让领域概念清晰明确。这样你和队友就不用猜 string $x 到底是什么东西了。
识别原始类型偏执
以下是 PHP 代码库中原始类型偏执的典型症状。
函数参数都是原始类型
php
public function scheduleEmailCampaign(
string $subject,
string $body,
string $sendAt, // ISO 格式?Y-m-d?时间戳?不知道
string $segmentId, // UUID?数字?内部编码?
string $timezone // IANA?偏移量?"local"?谁知道
): void {
// ...
}
都是合法的 PHP。但隐藏了很多东西:
sendAt大概是个日期时间segmentId应该是个领域 IDtimezone可能应该是个 Timezone 对象,或者至少是个受约束的值- 完全看不出哪些格式是合法的
你把大量领域规则编码在注释和约定里,而不是类型里。
用关联数组充当对象
php
$order = [
'id' => 123,
'total' => 199.99,
'currency' => 'USD',
'created_at' => '2025-11-27T10:00:00Z',
'status' => 'paid'
];
$this->processOrder($order);
processOrder 里面:
php
public function processOrder(array $order): void
{
if ($order['status'] === 'paid') {
// ...
}
if ($order['total'] > 100) {
// ...
}
// ...
}
$order 本质上就是个简陋的对象:
- 没有类型安全
- 没有保证(键可能缺失或类型错误)
- 没有封装的不变量
一个拼写错误($order['totla'])就会得到运行时 bug。
验证逻辑到处重复
当所有东西都是标量时,规则往往被重复:
php
// Controller
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email');
}
// Service
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email');
}
// Another class
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
// ...
}
如果邮箱验证规则改了(比如要支持 IDN 域名),你得记得在所有地方更新------否则 bug 就冒出来了。
原始类型偏执不只是风格问题。它有很实际的影响。
原始类型偏执的危害
领域规则分散
如果"有效邮箱"只是个字符串,任何字符串都能混进来。
最后你会:
- 到处"以防万一"地检查格式
- 有些地方忘了检查
- 不同层有略微不同的验证逻辑
意图不清晰
对比:
php
public function send(string $from, string $to, string $body): void
和:
php
public function send(EmailAddress $from, EmailAddress $to, EmailBody $body): void
第二个版本告诉你这些值是什么,而不只是它们的底层类型。
静态分析失效
PHP 8 的类型系统有帮助,但是:
string没说它是邮箱还是产品编码int没说它是分为单位的价格还是用户 ID
值对象给静态分析器(Psalm、PHPStan)和 IDE 更多结构信息。
测试变得啰嗦和重复
如果每个测试都要小心地设置字符串和整数:
php
$service->createDiscount('WELCOME10', 10.0, 'USD', 'percentage', time() + 3600);
同样的假设和格式一遍又一遍。用值对象就能把这些假设封装好。
解决方案:值对象
在领域驱动设计中,值对象是这样的小对象:
- 表示一个领域概念(Email、Money、Percentage、CouponCode、UserId......)
- 不可变(创建后不会改变)
- 按值比较,而不是身份
把它们想成"带行为的原始类型"。
不是:
php
string $email
string $currency
float $amount
string $couponCode
而是:
php
EmailAddress $email
Currency $currency
Money $amount
CouponCode $couponCode
下面构建几个具体例子。
EmailAddress 值对象
重构前
php
public function registerUser(string $email, string $password): void
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email address.');
}
// Save user
}
其他地方:
php
public function sendWelcomeEmail(string $email): void
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
// ...
}
// Send...
}
重构后
php
final class EmailAddress
{
private string $value;
private function __construct(string $email)
{
$email = trim($email);
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException(sprintf('Invalid email: "%s"', $email));
}
$this->value = strtolower($email);
}
public static function fromString(string $email): self
{
return new self($email);
}
public function value(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}
使用:
php
public function registerUser(EmailAddress $email, string $password): void
{
// 这里不需要再验证邮箱:
// 如果你拿到的是 EmailAddress,它已经是有效的了
}
public function sendWelcomeEmail(EmailAddress $email): void
{
// 直接用
$this->mailer->send((string)$email, 'Welcome!', '...');
}
好处:
- 验证集中且一致
- 意图清晰:这是邮箱,不是随便什么字符串
- 之后可以加更多便利方法:
php
public function domain(): string
public function localPart(): string
public function isFromFreemailProvider(): bool
controller 和 service 不用关心验证细节,直接用就行。
Money 和 Currency:告别浮点数陷阱
用 float 处理金额是经典的原始类型偏执罪行之一。
重构前
php
$total = 19.99;
$discount = 0.1; // 10%
$final = $total - ($total * $discount);
看起来没问题,但 float 会引入舍入误差:
php
var_dump(19.99 * 100); // 1998.9999999999...
加上货币就变成:
php
public function applyDiscount(float $amount, float $discount, string $currency): float
{
// 缺失:检查货币一致性、舍入策略等
}
重构后
更好的做法:
- 用最小单位(分)的整数表示金额
- 金额始终带着货币
php
final class Money
{
private int $amount; // 最小单位(如分)
private string $currency; // ISO 4217 代码如 "USD", "CNY"
private function __construct(int $amount, string $currency)
{
if ($amount < 0) {
throw new InvalidArgumentException('Money amount cannot be negative.');
}
// 可选:验证货币是否在白名单内
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
throw new InvalidArgumentException('Invalid currency code: ' . $currency);
}
$this->amount = $amount;
$this->currency = $currency;
}
public static function fromFloat(float $amount, string $currency): self
{
// 例子:存为分
$minor = (int) round($amount * 100);
return new self($minor, $currency);
}
public static function fromInt(int $amount, string $currency): self
{
return new self($amount, $currency);
}
public function amount(): int
{
return $this->amount;
}
public function currency(): string
{
return $this->currency;
}
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount + $other->amount, $this->currency);
}
public function subtract(self $other): self
{
$this->assertSameCurrency($other);
if ($other->amount > $this->amount) {
throw new RuntimeException('Cannot subtract more than available.');
}
return new self($this->amount - $other->amount, $this->currency);
}
public function multiply(float $factor): self
{
$newAmount = (int) round($this->amount * $factor);
return new self($newAmount, $this->currency);
}
public function equals(self $other): bool
{
return $this->currency === $other->currency && $this->amount === $other->amount;
}
public function format(): string
{
// 简单格式化(可以用 NumberFormatter 做本地化)
return sprintf('%s %.2f', $this->currency, $this->amount / 100);
}
private function assertSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw new RuntimeException(sprintf(
'Currency mismatch: %s vs %s',
$this->currency,
$other->currency
));
}
}
}
使用:
php
$price = Money::fromFloat(19.99, 'USD');
$discount = Money::fromFloat(5.00, 'USD');
$final = $price->subtract($discount);
echo $final->format(); // USD 14.99
现在你的函数可以这样设计:
php
public function applyCoupon(Money $price, CouponCode $coupon): Money
{
// ...
}
再也不用纠结"传的是 float 还是分"了,类型本身就说明一切。
类型化 ID
到处用裸的 int/string ID 是另一个隐蔽的原始类型习惯。
重构前
php
public function findUserById(int $id): User
{
// ...
}
public function assignUserToSegment(int $userId, int $segmentId): void
{
// ...
}
没有什么能阻止你意外地搞混它们:
php
$service->assignUserToSegment($segmentId, $userId); // 搞反了...
重构后
php
final class UserId
{
public function __construct(private int $value)
{
if ($this->value <= 0) {
throw new InvalidArgumentException('UserId must be positive.');
}
}
public function value(): int
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return (string) $this->value;
}
}
final class SegmentId
{
public function __construct(private int $value)
{
if ($this->value <= 0) {
throw new InvalidArgumentException('SegmentId must be positive.');
}
}
public function value(): int
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return (string) $this->value;
}
}
现在:
php
public function findUserById(UserId $id): User
{
// ...
}
public function assignUserToSegment(UserId $userId, SegmentId $segmentId): void
{
// ...
}
如果你想搞混参数:
php
$service->assignUserToSegment($segmentId, $userId);
你会立即得到反馈:
- 来自 IDE
- 来自静态分析(PHPStan/Psalm)
- 可能来自 PHP 本身(因为类型不匹配)
原始类型偏执让错误静默发生,值对象则让错误无处遁形。
用领域对象替代魔法数组
关联数组超级方便......也很危险。
不要这样:
php
$segmentRule = [
'field' => 'last_login_at',
'operator' => '>=',
'value' => '2025-11-01',
];
可以创建一个小对象:
php
final class SegmentRule
{
public function __construct(
private string $field,
private string $operator,
private string $value
) {
$this->validate();
}
private function validate(): void
{
if (!in_array($this->operator, ['=', '!=', '>=', '<=', '>', '<'], true)) {
throw new InvalidArgumentException('Invalid operator: ' . $this->operator);
}
if ($this->field === '') {
throw new InvalidArgumentException('Field name cannot be empty.');
}
}
public function field(): string
{
return $this->field;
}
public function operator(): string
{
return $this->operator;
}
public function value(): string
{
return $this->value;
}
}
使用:
php
$rule = new SegmentRule('last_login_at', '>=', '2025-11-01');
现在你可以逐步加行为:
public function appliesTo(User $user): bool- 根据
$field类型转换$value - 关于哪些 field/operator 组合是允许的复杂逻辑
这些都不用散落在代码库各处。
把握好度
到这里,你可能在想:
"所以我该把所有东西都包成值对象?BooleanFlag、PageNumber、Limit、Offset......?"
不用。
避免原始类型偏执不是要消灭原始类型。而是:
不要在原始类型会模糊领域含义或导致规则重复的地方使用它们。
适合做值对象的:
- 任何有验证规则的(email、URL、电话号码、优惠码)
- 任何单位/格式重要的(金额、百分比、日期范围、时长)
- 任何是核心领域语言的(UserId、ProductId、SegmentId、DiscountRule 等)
- 总是一起出现的值的复杂组合(价格+货币、纬度+经度、开始+结束日期)
用原始类型通常没问题的:
- 简单计数器(
int $retryCount) - 小的标志和开关(
bool $notifyUser) - 不会泄漏到领域边界的实现细节
拿不准的时候,问自己:
- "我们是不是到处都在为这个写验证逻辑?"
- "大家是不是老问'这个 string/int 应该是什么?'"
- "把行为和这个概念绑定在一起有好处吗?"
如果答案是肯定的,那就是值对象可能值得做的信号。
与 Laravel 和 Symfony 集成
你不需要和框架对着干才能用值对象。
Laravel
Form Request / Controller
可以在 controller 或 form request 里把输入包装成值对象:
php
public function store(RegisterUserRequest $request)
{
$email = EmailAddress::fromString($request->input('email'));
$this->service->registerUser($email, $request->input('password'));
}
Eloquent Casting
可以写自定义 cast 把数据库列映射到值对象(Laravel 7+):
php
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class EmailAddressCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
return EmailAddress::fromString($value);
}
public function set($model, string $key, $value, array $attributes)
{
if ($value instanceof EmailAddress) {
return $value->value();
}
return $value;
}
}
在模型里:
php
protected $casts = [
'email' => EmailAddressCast::class,
];
现在 $user->email 是个 EmailAddress,不只是字符串。
Symfony
使用 Translation/Validator 组件和 Doctrine:
- 验证规则可以移到值对象里或通过 Symfony Validator 共享
- Doctrine 可以把 embeddable 或自定义 DBAL 类型映射到你的值对象:
- Money 作为 embeddable
- EmailAddress 作为自定义类型
你的 controller 和 service 就能用领域类型而不是原始类型了。
值对象让测试更简单
值对象通常:
- 小
- 纯粹
- 无状态(不可变)
- 容易测试
EmailAddress 的测试示例(用 PHPUnit):
php
public function testItRejectsInvalidEmail(): void
{
$this->expectException(InvalidArgumentException::class);
EmailAddress::fromString('not-an-email');
}
public function testItNormalizesCase(): void
{
$email = EmailAddress::fromString('TeSt@Example.COM');
$this->assertSame('test@example.com', $email->value());
}
public function testEquality(): void
{
$a = EmailAddress::fromString('test@example.com');
$b = EmailAddress::fromString('TEST@example.com');
$this->assertTrue($a->equals($b));
}
有了这些测试,你就能确信系统中所有邮箱处理都是一致的------因为都走同一个对象。
渐进式迁移现有代码库
你不需要重写所有东西。可以逐步演进。
从边界入手
好的起点:
- HTTP controller / 路由
- CLI 命令
- 消息消费者(队列 worker)
- 外部 API 客户端
在这些边界:
- 尽早把裸的原始类型解析成值对象
- 在领域/应用服务内部使用值对象
- 只在需要序列化时(JSON、数据库等)转换回原始类型
允许两种方式并存
如果担心大规模重构:
php
public function registerUser(EmailAddress|string $email, string $password): void
{
if (is_string($email)) {
$email = EmailAddress::fromString($email);
}
// 方法剩余部分现在总是处理 EmailAddress
}
你可以逐步更新调用方,让它们传 EmailAddress 而不是字符串。
用静态分析工具辅助
像 PHPStan 或 Psalm 这样的工具可以:
- 强制类型(EmailAddress vs string)
- 尽早捕获不匹配
- 帮你找到所有还在为重要概念使用裸原始类型的地方
慢慢地,你的核心领域代码会越来越强类型、越来越有表达力。
总结
原始类型偏执很隐蔽。它看起来像"简单代码":
php
string $email
float $price
string $currency
int $id
array $order
但在规模变大后,它让你的代码:
- 更难理解("这个字符串代表什么?")
- 更容易出问题("我们在这里验证过邮箱吗?")
- 重构起来很痛苦("我们改了货币处理,现在到处着火")
避免原始类型偏执不是学术追求,而是为了:
- 给你的领域概念命名和结构
- 集中验证和不变量
- 在类型层面让意图明显
- 减少分散的逻辑和重复的检查
我们讲了:
- 在参数列表、数组和无类型值中识别原始类型偏执
- 构建和使用 EmailAddress、Money、UserId、SegmentId 和小型领域对象
- 平衡务实:包装重要的,而不是所有东西
- 与 Laravel 和 Symfony 集成而不是对抗它们
- 从边界开始逐步迁移现有代码库
下次你写这样的方法时:
php
public function applyDiscount(string $couponCode, float $amount, string $currency): float
停一下,问问自己:
"这真的只是字符串和 float......还是 CouponCode 和 Money?"
这个决定日积月累,最终会形成一个稳固、有表达力的代码库------对所有维护者都更友好,尤其是未来的你自己。