← Back to writing
5 min read

Bulletproofing Your Laravel Code with Value Objects

Laravel Php Learning

Harris Raftopoulos (Senior Software Engineer, Ulobby / Staff Writer, Laravel News) co-organises the Laravel Greece meetup. He gave versions of this talk at Laracon India 2026 and Laravel Live Japan 2026, where he also released Laravel Aegis on stage. The talk is built around the "primitive obsession" anti-pattern and how value objects fix it.

Value objects - the core pattern

Primitive obsession is passing raw strings, integers, and other primitives around an application and trusting they are valid. A plain string is not an email. An integer is not money. When those assumptions are wrong, bugs surface far from where the bad data entered the system.

A value object solves this by making validation part of construction. The constructor either produces a valid instance or throws. Once you hold an Email object, you know it is a real email address. Invalid values cannot propagate because they never become objects in the first place.

The secondary benefit is that related behaviour moves out of the model and into the object itself. An Email value object can have a domain() method. A Money object can have currency-aware arithmetic. This debloats the Eloquent model and makes the domain logic findable.

A properly written value object is a final readonly class with validation in the constructor, normalisation, an equals() method, and a Castable implementation so Eloquent can store and retrieve it. Written by hand, this runs to around 70 lines per class.

References:

Laravel Aegis

Laravel Aegis is Harris's package that scaffolds the boilerplate for a value object from a single Artisan command.

composer require harrisrafto/laravel-aegis

php artisan make:value-object Email \
    --rule=email \
    --normalize=lower \
    --method=domain:string \
    --cast=Order.email

This generates three things. First, app/Domain/ValueObjects/Email.php, a final readonly class with validation, normalisation, Stringable, JsonSerializable, the Eloquent Castable block, and a stub for the domain() method. Second, a test stub in tests/Unit/EmailTest.php using Pest if it is installed, otherwise PHPUnit. Third, it patches the model's casts() method directly, adding 'email' => Email::class.

Validation in FormRequests

Laravel Aegis registers a valueObject macro on Rule so the value object validates itself in a FormRequest:

use Illuminate\Validation\Rule;

public function rules(): array
{
    return [
        'email' => ['required', Rule::valueObject(Email::class)],
    ];
}

Add the ResolvesValueObjects trait to the FormRequest and pull the validated instance straight out in the controller:

$email = $request->valueObject('email'); // Email instance, already validated

Scanning an existing codebase

php artisan vo:scan

Aegis walks Eloquent models and migrations, identifies columns matching common patterns (email, url, uuid, country_code, slug, ip, status, money), and prints the exact make:value-object command to run for each. It outputs a coverage percentage showing what proportion of candidate columns are already wrapped.

Requirements

  • PHP 8.3+
  • Laravel 13

References:

filter_var

filter_var is a native PHP function for validating and sanitising common data types without a library. It is likely referenced in the talk as the underlying mechanism for validating emails, URLs, and IPs inside value objects.

Common validation filters:

filter_var($value, FILTER_VALIDATE_EMAIL);     // email
filter_var($value, FILTER_VALIDATE_URL);       // URL
filter_var($value, FILTER_VALIDATE_IP);        // IP address (IPv4 or IPv6)
filter_var($value, FILTER_VALIDATE_INT);       // integer, optionally with range
filter_var($value, FILTER_VALIDATE_BOOLEAN);   // boolean

Sanitise before validating where needed:

$email = filter_var($raw, FILTER_SANITIZE_EMAIL);
$valid = filter_var($email, FILTER_VALIDATE_EMAIL);

IP validation supports flags to restrict to IPv4, IPv6, or exclude private/reserved ranges:

filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE);

filter_var returns the filtered value on success or false on failure. It is a reasonable building block for value object constructors where the input type maps to one of the supported filters.

References:

Zed editor - bonus tooling

Zed is a code editor written in Rust, built for performance. It is noticeably faster than VS Code for large projects and has built-in multiplayer collaboration. Available on macOS, Linux, and Windows.

PHP and Laravel support comes via extensions:

  • The official Laravel extension adds go-to-definition for views, routes, config keys, translations, env values, Blade components, Livewire references, middleware, and container bindings. It also includes autocomplete for model properties, query-chain methods, and Blade variables, plus diagnostics for missing views and invalid column references.
  • Laravel Pint can be configured to run on save via the language server formatter settings.
  • The community zed-for-laravel project provides a one-command installer bundling Pint formatting, 200+ snippets (including Pest, Filament, Inertia), and pre-configured Artisan tasks.

As of March 2026, Tinkerwell added Zed to its list of supported editors.

References:

Tinkerwell - bonus tooling

Tinkerwell is a standalone code runner for PHP and Laravel by Beyond Code. It is php artisan tinker with a proper editor interface, autocompletion, inline output, and remote connection support.

Key features:

  • Run code locally within any Laravel project context without creating routes or test files
  • Connect remotely to production via SSH, Docker, Laravel Vapor, or Laravel Cloud to run Eloquent queries and one-off scripts without deploying files
  • Output-aware display - renders emails, tables, and object graphs depending on what the code returns
  • AI code completion and Conversational Mode added in v5 (July 2025), plus an MCP server so Claude Code, Cursor, and similar tools can execute code through Tinkerwell
  • Saved snippets and history

Current version as of the talk is v5, which supports Laravel 13 and PHP 8.3+.

References:


Notes from Laravel Live UK 2026.