Back to Blog
Lesson 45 of the Intermediate Laravel: Real-World Application Patterns course
LaravelJune 26, 20264 min read

Using Value Objects: Encapsulating Domain Logic in Laravel

Learn how to use Value Objects in Laravel to eliminate primitive obsession, encapsulate attribute logic, and improve domain clarity in your models.

laravelddddomain-driven designvalue objectsarchitecturephpbackend

Previously in this course, we discussed Project Structure for Large Applications: Domain-Driven Laravel, emphasizing how to organize your code to support growth. In this lesson, we drill down into the domain layer by introducing Value Objects, a core concept in domain-driven design (DDD) that helps us stop treating our business data as mere arrays of strings and integers.

The Problem: Primitive Obsession

In a typical Laravel application, we often store business data using scalar types. For example, a Task might have a priority stored as an integer (e.g., 1, 2, 3) or a status stored as a string ('pending', 'active').

When we do this, the logic for handling these values—like checking if a priority is valid or formatting a status label—often leaks into our controllers or gets buried in bloated Eloquent models. This is "primitive obsession." It leads to code duplication and makes it impossible to enforce domain rules consistently.

A Value Object is a small, immutable object that represents a specific concept in your domain. It is defined by its attributes rather than an identity (like a database ID).

Creating Your First Value Object

Let's improve our project board by introducing a Priority value object. Instead of checking if ($task->priority === 3) everywhere, we encapsulate that logic.

Create a new directory app/Domain/Tasks/ValueObjects and add the Priority class:

PHP
namespace App\Domain\Tasks\ValueObjects;

use InvalidArgumentException;

readonly class Priority
{
    public const LOW = 1;
    public const MEDIUM = 2;
    public const HIGH = 3;

    public function __construct(public int $value)
    {
        if (!in_array($value, [self::LOW, self::MEDIUM, self::HIGH], true)) {
            throw new InvalidArgumentException("Invalid priority value: {$value}");
        }
    }

    public function label(): string
    {
        return match($this->value) {
            self::LOW => 'Low Priority',
            self::MEDIUM => 'Medium Priority',
            self::HIGH => 'High Priority',
        };
    }
}

Integrating with Eloquent

To use this in your Task model, you can use Eloquent's Casts. This keeps your database schema simple while your application code remains expressive.

Update your Task model to cast the priority column:

PHP
namespace App\Models;

use App\Domain\Tasks\ValueObjects\Priority;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;

class Task extends Model
{
    protected function casts(): array
    {
        return [
            'priority' => 'integer',
        ];
    }

    protected function priority(): Attribute
    {
        return Attribute::make(
            get: fn($value) => new Priority($value),
            set: fn(Priority $value) => $value->value,
        );
    }
}

Now, instead of passing integers around, you work with the object:

PHP
#6A9955">// In a Controller or Service
$task->priority = new Priority(Priority::HIGH);

echo $task->priority->label(); #6A9955">// "High Priority"

Why This Improves Domain Clarity

By using Value Objects, you achieve three things:

  1. Self-Validation: The object cannot exist in an invalid state. You cannot create a Priority with a value of 99.
  2. Encapsulation: If the definition of "High" changes, you only update the Priority class.
  3. Readability: $task->priority->label() is self-documenting compared to a raw integer check.

If you are interested in how similar patterns appear in other stacks, check out our Laravel Value Objects: A Beginner’s Guide to Encapsulating Domain Logic for a broader perspective on the pattern.

Hands-on Exercise

  1. Create a TaskStatus Value Object for your project board.
  2. Implement constants for Draft, InProgress, and Completed.
  3. Add a method isFinished() to the TaskStatus object that returns a boolean.
  4. Update your Task model to cast the status column to this new object.
  5. Refactor a controller method to use $task->status->isFinished() instead of checking a string.

Common Pitfalls

  • Mutating Value Objects: Value Objects should be immutable. If you need to change a value, return a new instance of the object instead of modifying the existing one.
  • Over-engineering: Don't create a Value Object for every single attribute. Use them only when the attribute has specific behavior or validation rules that justify the extra class.
  • Database Coupling: Remember that the Value Object is a domain concept. If your database structure changes, your Value Object should ideally remain untouched, provided you handle the translation in the model's cast.

Recap

Value Objects allow us to move logic out of our models and controllers into dedicated, type-safe classes. They effectively fight "primitive obsession" and make your domain code significantly easier to test and maintain. By using Eloquent's Attribute casting, we can integrate these objects seamlessly into our existing workflows.

Up next: We will apply the Strategy Pattern for Business Rules to handle complex, branching logic within our services.

Similar Posts