Lesson 2 of 3 · Small Objects, Big Impact
Primitive Obsession
The habit hiding in every codebase
Open almost any PHP codebase and you'll see this:
<?php
declare(strict_types=1);
namespace App\Booking;
final class BookingService
{
public function book(int $roomId, string $from, string $to, int $cents): void
{
// ...
}
public function notifyGuest(string $email, int $guestId, float $amount): void
{
// ...
}
public function applyRatePlan(string $code, string $currency, float $discount): void
{
// ...
}
}Strings, ints, floats, and arrays everywhere. Each one is doing real work. Each one is also a quiet mistake.
string $email accepts any string. int $guestId accepts any integer. float $amount accepts any number with a decimal point. PHP is happy. The IDE is happy. The codebase, once enough callers depend on those signatures, is not.
This is primitive obsession: the habit of using language primitives (string, int, float, bool, array) where a small custom type would have been right. It's one of the cheapest code smells to fix, which is why it's the first tool in the toolkit.
What primitives don't do for you
A primitive type tells PHP what shape a value has. It doesn't tell anyone what the value is for.
string $email and string $apiKey and string $reservationReference all look identical to the type system. They're all just strings. PHP can't tell them apart, your IDE can't suggest the right one in autocomplete, and a developer reading the call site is one tab-completion away from passing the API key into the email field by mistake.
A few specific things primitives don't do:
They don't carry validation. Every place an email is used has to remember to validate it as an email. Every place a positive amount is required has to remember to check
> 0.They don't carry behavior. "Format this currency for display" lives somewhere else. "Mask this credit card number" lives somewhere else. The data is in one place; the operations on it are scattered.
They don't carry meaning. A function with
function transfer(int $a, int $b, int $c)tells you nothing. The third int could be cents, dollars, account ID, or a percentage.They don't catch mistakes. Swap two arguments of the same primitive type and the type system can't help you. The bug ships, runs, and surfaces in production when someone notices the wrong account got debited.
The cost of all this is small per occurrence and enormous in aggregate. Every primitive used as a domain concept is a tiny tax on every reading, every change, and every test.
The simplest fix
Here's the same idea with a custom type:
<?php
declare(strict_types=1);
namespace App\Booking;
use InvalidArgumentException;
final readonly class EmailAddress
{
public function __construct(public string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Invalid email: {$value}");
}
}
public function domain(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}
public function __toString(): string
{
return $this->value;
}
}Validation lives in one place. The constructor is the only way to create an EmailAddress, and the constructor refuses to create an invalid one. If you have an EmailAddress instance, you have a valid email, no exceptions. Every consumer of EmailAddress gets that guarantee for free.
Behavior lives with the data. $email->domain() is right where it belongs. The next time you need to extract the domain, you don't reimplement it; you call the method that already exists.
Meaning lives in the type. function notifyGuest(EmailAddress $email) tells you exactly what's expected. No room for "is this maybe an API key?". No room for tab-completion errors at the call site.
Mistakes get caught at the boundary. Pass a phone number string into a function that expects EmailAddress and PHP throws a TypeError. PHPStan or your IDE flags it before it runs.
A worked example: money
The damage primitive obsession can do scales with the seriousness of the concept. Money is the canonical case.
The naive version:
<?php
declare(strict_types=1);
namespace App\Billing;
final class ReservationPayer
{
public function authorize(string $reservationId, float $amount, string $currency): void
{
$cents = (int) ($amount * 100);
// hand $reservationId, $cents and $currency to the payment provider
}
}What can go wrong:
float $amountinvites floating-point math.0.1 + 0.2 = 0.30000000000000004. In production, with money.string $currencyaccepts"USD","usd","$","dollars", and"USDD". The constraint that it has to be a valid ISO currency code lives in someone's head.(int) ($amount * 100)is a casting trick that works most of the time and breaks for currencies with three decimal places (Bahraini dinar, Tunisian dinar) or zero decimal places (Japanese yen, Korean won).The function signature gives you no hint that any of this matters.
The version with a Money value object (trimmed to what this lesson needs):
<?php
declare(strict_types=1);
namespace App\Billing;
use InvalidArgumentException;
final readonly class Money
{
public function __construct(public int $amountInCents)
{
if ($amountInCents < 0) {
throw new InvalidArgumentException("Money cannot be negative");
}
}
public static function fromCents(int $amountInCents): self
{
return new self($amountInCents);
}
public function add(Money $other): self
{
return new self($this->amountInCents + $other->amountInCents);
}
}This is deliberately cut down. The full Money class, paired with a Currency value object that stops you from adding USD to EUR, lives in the Value Objects lesson later in this chapter. Here, the point is just the shape of the fix.
Now the function looks like this:
<?php
declare(strict_types=1);
namespace App\Billing;
use App\Payments\Payments;
use App\Reservations\ReservationId;
final class ReservationPayer
{
public function __construct(private Payments $payments) {}
public function authorize(ReservationId $reservationId, Money $amount): void
{
$this->payments->authorize($reservationId, $amount);
}
}ReservationId follows the same shape as the GuestId below, and Payments is the payment port you'll meet in Lesson 18. Here is the identity type:
<?php
declare(strict_types=1);
namespace App\Booking;
use Ramsey\Uuid\Uuid;
final readonly class GuestId
{
public function __construct(public string $value) {}
public static function next(): self
{
return new self(Uuid::uuid7()->toString());
}
}An ID wrapped like this can't be passed where a room ID is expected, the same-type mix-up from the list above.
Three things have changed without you noticing:
You can't have negative money. The constructor refuses.
You can't lose precision through floating-point math. The type stores integer cents.
The signature reads like a sentence. "Authorize this amount of money for this reservation."
The full version in the Value Objects lesson adds a fourth: you can't add USD to EUR by accident, because the Currency on each Money has to match.
The cost is one class, and the benefit applies to every place money is used in the codebase.
DTOs, value objects, named constructors and immutable objects are the next four lessons, each a different shape of the same idea: a small object whose impact lands on every line that touches the concept it names.
When primitives are fine
To stay honest with the "tools not rules" framing from Lesson 1: primitives are fine in plenty of places.
Local variables in a single function. A loop counter is
int $i. A temporary buffer isstring $buffer. Wrapping these in custom types would be over-engineering.Genuine technical primitives. A byte count, a millisecond duration, a port number - these are usually fine as primitives unless your domain treats them as more than that.
One-off internal scripts. If the code lives in
bin/and runs rarely, primitives are usually the right level of structure.The framework boundary. The HTTP request gives you strings. The database gives you strings, ints, and floats. Wrapping primitives into domain types happens at the boundary, not before it. It's fine for the controller to receive
string $emailfrom the request and convert it toEmailAddressbefore passing it inward.
The tool earns its place when the same concept appears in more than one or two places, when validation has a real meaning, when behavior is being scattered, or when the type signatures are hiding what the code actually does.
The smallest first step
If you want to try this in a real codebase tomorrow, do not start by wrapping every primitive in a value object.
Pick one concept. Pick one that's been a problem: a value that gets validated in three different places, an ID that's been mixed up with another ID once, a money amount that someone got wrong with cents-vs-dollars. Make a value object for that one concept. Use it in the use case where the pain is most visible. Watch what happens in code review.
Two outcomes are possible. Either the value object earns its place and the team starts asking why other concepts don't have one, or it feels like overhead, in which case you've learned that this part of your codebase isn't where the tool fits.
The one thing to remember
When you find yourself writing the same validation, the same parsing, or the same formatting code for the third time on what's nominally "just a string", you've found a value object that wants to be born. Write it.
Related Links
- Primitive ObsessionA description of the code smell this lesson is named after, with more examples of primitives standing in for real concepts.
- Replace Primitive with ObjectFowler's refactoring catalog entry for the exact move this lesson walks through.
- moneyphp/moneyThe PHP library this lesson's money example is modeled after, with a full implementation instead of a trimmed one.