Primitive Obsession
debt(d7/e7/b5/t5)
Closest to 'only careful code review or runtime testing' (d7). PHPStan/Psalm don't flag primitive obsession directly — they accept string/int parameters happily. Detection requires human judgment about whether a primitive represents a domain concept.
Closest to 'cross-cutting refactor across the codebase' (e7). The quick_fix says replace a cluster of primitives with a Value Object, but in practice every call site, function signature, serialization boundary, and storage layer that touched the primitive must change — emails/money/IDs travel everywhere.
Closest to 'persistent productivity tax' (b5). applies_to spans web/cli/queue contexts; raw primitives for domain concepts force scattered validation and defensive checks across many work streams, but the system can still function and be incrementally improved.
Closest to 'notable trap (documented gotcha)' (t5). Per the misconception, devs believe primitives are simpler — but learn through bugs (passing userId where productId expected, float money rounding) that value objects prevent whole classes of errors. A well-known smell most experienced devs eventually internalize.
Also Known As
TL;DR
Explanation
Primitive obsession is using primitive types (string, int, bool, array) for concepts that deserve their own class — e.g. representing a phone number as a plain string, a money amount as a float, or a coordinate pair as two separate variables. Primitives have no domain validation, no domain-specific methods, and no documentation of their meaning. Introducing small value objects (PhoneNumber, Money, Coordinate) gives the type a name, centralises validation, and makes illegal states unrepresentable.
Common Misconception
Why It Matters
Common Mistakes
- Passing $email as a string everywhere instead of an Email value object that validates on construction.
- Using int for money — floating point errors and currency handling belong in a Money class.
- Not noticing when a cluster of primitives always travels together — that is a data clump and a value object candidate.
- Creating value objects without making them immutable — a mutable Email can change to invalid state after construction.
Code Examples
function registerUser(string $email, string $role, int $age): void {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Bad email');
}
// $email, $role, $age scattered everywhere as raw strings/ints
}
readonly class Email {
public function __construct(public string $value) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email: $value");
}
}
}
function registerUser(Email $email, Role $role, Age $age): void {
// types carry their own validation — impossible to pass invalid data
}