Keywords: php, primitive obsession, value object, domain-driven design, clean code, refactoring, php 8, types, money object, email value object, php arrays, laravel, symfony

1. "Everything Is a String": The Hidden Smell in PHP Codebases

PHP makes it incredibly easy to start shipping features.

  • Need an email? Just use a string.
  • Need a price? Eh, float is fine.
  • Need some user data? Just throw stuff in an array.

It feels fast — until it doesn't.

You start seeing functions like this:

function registerUser(
    string $email,
    string $password,
    string $firstName,
    string $lastName,
    string $countryCode
) {
    // ...
}

Or service methods like:

public function createDiscount(
    string $code,
    float $amount,
    string $currency,
    string $type,
    int $expiresAtTimestamp
): void {
    // ...
}

Everything is primitives: string, int, float, array. Your domain concepts accidentally become "just data".

That's primitive obsession:

Relying on primitive types (strings, ints, arrays, floats) to represent domain concepts that deserve their own types.

In this article, we'll explore:

  • How primitive obsession shows up in PHP (especially with arrays and loosely typed values)
  • Why it makes your code harder to change and easier to break
  • How to refactor toward value objects step by step
  • Concrete examples: EmailAddress, Money, CouponCode, typed IDs, collections
  • How this plays nicely with modern PHP features and frameworks like Laravel & Symfony

By the end, you'll see that avoiding primitive obsession isn't about being "OO-purist". It's about making your domain explicit, so future you (and your teammates) stop guessing what a string $x was supposed to represent.

2. Spotting Primitive Obsession in Daily PHP Work

Let's make this concrete. Here are typical signs you've got primitive obsession in your PHP codebase.

2.1 Long parameter lists of primitives

public function scheduleEmailCampaign(
    string $subject,
    string $body,
    string $sendAt,     // ISO string? Y-m-d? timestamp? no idea
    string $segmentId,  // is this an UUID? numeric? internal code?
    string $timezone    // IANA? offset? "local"? who knows
): void {
    // ...
}

All valid PHP. But it hides a lot:

  • sendAt is probably a date-time
  • segmentId is likely a domain ID
  • timezone should probably be a Timezone object, or at least a constrained value
  • There's zero clue which formats are valid

You're encoding lots of domain rules in comments and convention instead of types.

2.2 Associative arrays used as "objects"

$order = [
    'id'         => 123,
    'total'      => 199.99,
    'currency'   => 'USD',
    'created_at' => '2025-11-27T10:00:00Z',
    'status'     => 'paid'
];
$this->processOrder($order);

Inside processOrder:

public function processOrder(array $order): void
{
    if ($order['status'] === 'paid') {
        // ...
    }
    if ($order['total'] > 100) {
        // ...
    }
    // ...
}

$order is basically a mini object, but with:

  • No type safety
  • No guarantees (keys may be missing or mis-typed)
  • No encapsulated invariants

One typo ($order['totla']) and you get a runtime bug.

2.3 Duplication of rules and formats

When everything is a scalar, rules are often duplicated:

// 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)) {
    // ...
}

If email validation changes (e.g., allow IDN domains), you have to remember to update it everywhere — or bugs pop up.

Primitive obsession is not just a style issue. It has very real effects.

3. Why Primitive Obsession Hurts Your PHP Code

3.1 Domain rules are scattered and fragile

If "valid email" is just a string, any string can sneak in.

You end up:

  • Checking formats "just in case" everywhere
  • Forgetting checks in some places
  • Having slightly different validation in different layers

3.2 Intent becomes vague

Compare:

public function send(string $from, string $to, string $body): void

with:

public function send(EmailAddress $from, EmailAddress $to, EmailBody $body): void

The second version tells you what the values mean, not just what low-level type they are.

3.3 Static analysis and IDE help are weaker

PHP 8's type system helps, but:

  • string doesn't tell you if it's an email or a product code
  • int doesn't tell you if it's a price in cents or a user ID

Value objects give static analyzers (Psalm, PHPStan) and IDEs more structure to work with.

3.4 Testing gets noisy and repetitive

If every test has to set up strings/ints carefully:

$service->createDiscount('WELCOME10', 10.0, 'USD', 'percentage', time() + 3600);

You repeat the same assumptions and formats over and over. Value objects let you bundle those assumptions cleanly.

4. The Antidote: Value Objects in PHP

In Domain-Driven Design, Value Objects are small objects that:

  • Represent a domain concept (Email, Money, Percentage, CouponCode, UserId, …)
  • Are immutable (once created, they don't change)
  • Are compared by their value, not identity

Think of them as "smart primitives".

Instead of:

string $email
string $currency
float  $amount
string $couponCode

You get:

EmailAddress $email
Currency $currency
Money $amount
CouponCode $couponCode

Let's build a few.

5. Example 1: EmailAddress Value Object

5.1 Before: strings everywhere

public function registerUser(string $email, string $password): void
{
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        throw new InvalidArgumentException('Invalid email address.');
    }
    // Save user
}

Other places:

public function sendWelcomeEmail(string $email): void
{
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        // ...
    }
    // Send...
}

5.2 After: EmailAddress encapsulates rules

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;
    }
}

Usage:

public function registerUser(EmailAddress $email, string $password): void
{
    // No need to re-validate email here:
    // if you're holding EmailAddress, it's already valid.
}
public function sendWelcomeEmail(EmailAddress $email): void
{
    // Just use it
    $this->mailer->send((string)$email, 'Welcome!', '...');
}

Benefits:

  • Validation is centralized and consistent
  • Intent is clear: this is an email, not any string

You can add extra convenience later:

  • public function domain(): string
  • public function localPart(): string
  • public function isFromFreemailProvider(): bool

No controller/service needs to know these rules. They just use the object.

6. Example 2: Money and Currency (Goodbye Float Hell)

Handling money with float is one of the classic primitive obsession sins.

6.1 The usual PHP approach

$total = 19.99;
$discount = 0.1; // 10%
$final = $total - ($total * $discount);

This looks harmless, but floats can introduce rounding errors:

var_dump(19.99 * 100); // 1998.9999999999...

Now add currency and you get:

public function applyDiscount(float $amount, float $discount, string $currency): float
{
    // Missing: check currency consistency, rounding policy, etc.
}

6.2 Money value object (with currency)

A better approach:

  • Represent amounts as integers in smallest units (cents)
  • Always carry currency with the amount
final class Money {
    private int $amount; // in minor units (e.g. cents)
    private string $currency; // ISO 4217 code like "USD", "IDR"

    private function __construct(int $amount, string $currency) {
        if ($amount < 0) {
            throw new InvalidArgumentException('Money amount cannot be negative.');
        }

        // Optionally validate currency against a whitelist
        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 {
        // Example: store as cents
        $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 {
        // Simple formatting (you can use NumberFormatter for locale-specific)
        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));
        }
    }
}

Usage:

$price = Money::fromFloat(19.99, 'USD');
$discount = Money::fromFloat(5.00, 'USD');
$final = $price->subtract($discount);
echo $final->format(); // USD 14.99

Now your functions can be designed like:

public function applyCoupon(Money $price, CouponCode $coupon): Money
{
    // ...
}

No more "did we pass in the price as float or cents?" confusion. The type itself carries the domain meaning.

7. Example 3: Typed IDs and Domain-Specific Types

Using raw int/string IDs everywhere is another sneaky primitive habit.

7.1 Before: ids as generic ints

public function findUserById(int $id): User
{
    // ...
}
public function assignUserToSegment(int $userId, int $segmentId): void
{
    // ...
}

Nothing stops you from accidentally swapping them:

$service->assignUserToSegment($segmentId, $userId); // Oops…

7.2 After: UserId and SegmentId value objects

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;
    }
}

Now:

public function findUserById(UserId $id): User
{
    // ...
}
public function assignUserToSegment(UserId $userId, SegmentId $segmentId): void
{
    // ...
}

If you try to swap arguments:

$service->assignUserToSegment($segmentId, $userId);

You get immediate feedback:

  • From your IDE
  • From static analysis (PHPStan/Psalm)
  • Possibly from PHP itself (because types don't match)

Primitive obsession silently allows mismatches; value objects make them impossible (or at least very obvious).

8. Getting Rid of "Magic Arrays": Tiny Domain Objects Instead

Associative arrays are incredibly convenient… and dangerous.

Instead of this:

$segmentRule = [
    'field'    => 'last_login_at',
    'operator' => '>=',
    'value'    => '2025-11-01',
];

You can create a tiny object:

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;
    }
}

Usage:

$rule = new SegmentRule('last_login_at', '>=', '2025-11-01');

Now you can add behavior over time:

  • public function appliesTo(User $user): bool
  • Conversion of $value based on $field type
  • Complex logic about what combinations of field/operator are allowed

All without scattering rules across your codebase.

9. How Far Should You Go? Being Pragmatic, Not Dogmatic

At this point, you might be thinking:

"So should I wrap everything in a value object? BooleanFlag, PageNumber, Limit, Offset…?"

No.

Avoiding primitive obsession doesn't mean eliminating primitives. It means:

Don't use primitives where they obscure domain meaning or duplicate rules.

Good candidates for value objects:

  • Anything with validation rules (email, URL, phone number, coupon code)
  • Anything where units/format matter (money, percentages, date ranges, durations)
  • Anything that is core to your domain language (UserId, ProductId, SegmentId, DiscountRule, etc.)
  • Complex combinations of values that always travel together (price + currency, latitude + longitude, start + end date)

Things that are often fine as primitives:

  • Simple counters (int $retryCount)
  • Small flags and toggles (bool $notifyUser)
  • Implementation details that don't leak into domain boundaries

When in doubt, ask:

  1. "Do we keep writing validation logic for this everywhere?"
  2. "Do people keep asking 'what is this string/int supposed to be?'"
  3. "Would we benefit from bundling behavior with this concept?"

If the answer is yes, that's a hint that a value object might be worth it.

10. Working with Frameworks: Laravel & Symfony Friendly

You don't have to fight your framework to use value objects.

10.1 Laravel

  • Form Requests / Controllers You can wrap inputs into value objects in your controller or form request:
public function store(RegisterUserRequest $request) {
    $email =EmailAddress: :fromString($request->input('email'));
    $this->service->registerUser($email, $request->input('password'));
}
  • Eloquent casting You can write custom casts to map DB columns to value objects (Laravel 7+):
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;
    }
}
  • In your model:
protected $casts = 
[     
  'email' => EmailAddressCast::class, 
];

Now $user->email is an EmailAddress, not just a string.

10.2 Symfony

Using the Translation/Validator components and Doctrine:

  • Validation Rules can be moved into value objects or shared via Symfony Validator

Doctrine can map embeddables or custom DBAL types to your value objects:

  • Money as embeddable
  • EmailAddress as custom type

Your controllers and services then work with domain types instead of primitives.

11. Testing Value Objects: A Nice Side Effect

Value objects are often:

  • Small
  • Pure
  • Stateless (immutable)
  • Easy to test

Example test for EmailAddress (using PHPUnit):

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));
}

Tests like this give you confidence that everywhere else in your system, email behavior is consistent, because it all goes through the same object.

12. Migrating an Existing PHP Codebase Away from Primitive Obsession

You don't have to rewrite everything. You can evolve gradually.

12.1 Start at the boundaries

Good starting points:

  • HTTP controllers / routes
  • CLI commands
  • Message consumers (queue workers)
  • External API clients

At those boundaries:

  • Parse raw primitives into value objects as early as possible
  • Use value objects inside your domain/application services
  • Convert back to primitives only when you need to serialize (JSON, DB, etc.)

12.2 Keep both worlds for a while

If you're worried about big refactors:

public function registerUser(EmailAddress|string $email, string $password): void
{
    if (is_string($email)) {
        $email = EmailAddress::fromString($email);
    }
    // Rest of method now always deals with EmailAddress
}

You can gradually update call sites to pass EmailAddress instead of strings.

12.3 Use static analysis to enforce usage over time

Tools like PHPStan or Psalm can:

  • Enforce types (EmailAddress vs string)
  • Catch mismatches early
  • Help you find all the places you're still using raw primitives for important concepts

Over time, your core domain becomes more and more strongly typed and expressive.

13. Wrapping Up: Let Your Domain Speak PHP, Not Just Strings

Primitive obsession is sneaky. It looks like "simple code":

string $email
float  $price
string $currency
int    $id
array  $order

But at scale, it makes your code:

  • Harder to understand ("what does this string represent?")
  • Easier to break ("did we validate this email here?")
  • Painful to refactor ("we changed currency handling and now everything is on fire")

Avoiding primitive obsession in PHP is not about being academic. It's about:

  • Giving names and structure to your domain concepts
  • Centralizing validation and invariants
  • Making your intent obvious at the type level
  • Reducing scattered logic and duplicated checks

We walked through:

  • Recognizing primitive obsession in parameter lists, arrays, and untyped values
  • Building and using EmailAddress, Money, UserId, SegmentId, and small domain objects
  • Balancing pragmatism: wrapping what matters, not everything
  • Integrating with Laravel and Symfony instead of fighting them
  • Gradually migrating existing codebases by starting at boundaries

The next time you write a method like:

public function applyDiscount(string $couponCode, float $amount, string $currency): float

pause for a moment and ask:

"Is this really just a string and a float… or is this a CouponCode and a Money?"

That one decision compounds over time into a codebase that feels sturdy, expressive, and a lot kinder to the people who have to work in it — especially future you.