Skip to content

Lesson 2 of 3 · Small Objects, Big Impact

Primitive Obsession

Beginner•
lesson•
October 7, 2026•
~7 min read

The habit hiding in every codebase

Open almost any PHP codebase and you'll see this:

PHP
<?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
<?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
<?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 $amount invites floating-point math. 0.1 + 0.2 = 0.30000000000000004. In production, with money.

  • string $currency accepts "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
<?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
<?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
<?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 is string $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 $email from the request and convert it to EmailAddress before 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.

Course Content

Introduction - Tools, Not Rules
Lesson
Primitive Obsession
Lesson
Sign in to track your progress
Data Transfer Objects
Lesson