Aegis for Laravel: Scaffolding and Validation Helpers for Value Objects
Published on by Harris Raftopoulos
I just released Aegis for Laravel on stage at Laravel Live Japan 2026. It's a package I built around a pattern I keep coming back to: Value Objects. They quietly remove a whole class of bugs that primitives cause, but writing them by hand gets tedious fast. Aegis writes the tedious part for you.
The Problem
A string isn't an email until something validates it. An int isn't money until something tags it with a currency. We pass these raw primitives around our apps and trust they're correct, then lose an afternoon to the one place they weren't.
A Value Object fixes that. Its constructor takes the input and either produces a valid instance or throws. Once you're holding an Email, it's a real email. Bad values never reach the rest of the system.
The catch is the boilerplate. A Value Object that does its job properly runs about 70 lines of PHP. A final readonly class. Validation in the constructor. Normalization. An equals() method. The Castable block with get, set, and compare so Eloquent can store it. Typing all of that for every string a team wants to harden costs more than the string was costing you. So people skip it, and the primitives stay.
Aegis writes those lines for you. One Artisan command produces the class with everything wired up, plus a test stub. You fill in the methods that belong to your domain.
Installation
composer require harrisrafto/laravel-aegis
The service provider registers itself through Laravel's package auto-discovery.
If you want to change the namespace for generated Value Objects, publish the config:
php artisan vendor:publish --tag=aegis-config
Scaffolding a Value Object
Here's the command for an Email:
php artisan make:value-object Email \ --rule=email \ --normalize=lower \ --method=domain:string \ --cast=Order.email
That generates three things.
First, app/Domain/ValueObjects/Email.php. It's final readonly, validated, and normalized. It implements Stringable and JsonSerializable so it behaves at the edges of your app, and it carries the Castable block for Eloquent. The domain(): string method you asked for is there as an empty stub, waiting for your logic.
Second, tests/Unit/EmailTest.php. If Pest is in your vendor/, Aegis writes a Pest it()->todo(). If it isn't, you get a PHPUnit markTestIncomplete() case instead. Either way the test exists and is asking to be filled in.
Third, it patches app/Models/Order.php, adding 'email' => Email::class to the model's casts() method. It preserves your existing indentation, and it's safe to run again. The cast gets added once.
Validating With the Same Value Object
The Value Object already knows what a valid email is. So you validate with it directly instead of repeating the rules in a FormRequest:
use Illuminate\Validation\Rule; public function rules(): array{ return [ 'email' => ['required', Rule::valueObject(Email::class)], ];}
Aegis registers valueObject as a macro on Illuminate\Validation\Rule. The call site reads like any other built-in rule, right next to Rule::in() or Rule::unique().
Resolving the Validated Instance
Once validation passes, you usually want the object, not the raw string. Add the ResolvesValueObjects trait to your FormRequest:
use HarrisRafto\Aegis\Concerns\ResolvesValueObjects; class StoreUserRequest extends FormRequest{ use ResolvesValueObjects; public function rules(): array { return [ 'email' => ['required', Rule::valueObject(Email::class)], ]; }}
Then pull the instance out in your controller:
$email = $request->valueObject('email'); // an Email instance, already validated
The object that validated the input is the one you carry forward, so there's no second copy of the truth to keep in sync.
Finding Candidates in Your Codebase
If you're adding Value Objects to an app that already exists, the hard part is knowing where to start. Run:
php artisan vo:scan
Aegis walks your Eloquent models and migrations, flags column names that match common patterns (email, url, uuid, country_code, slug, ip, status, money), and prints the exact make:value-object command for each one:
app/Models/Customer.php · billing_email → php artisan make:value-object Email --rule=email --normalize=lower --cast=Customer.billing_email · country_code → php artisan make:value-object CountryCode --rule=regex:/^[A-Z]{2}$/ --normalize=upper --cast=Customer.country_code · monthly_amount_cents candidate — Money column, see cknow/laravel-money Scanned 3 models, 16 columns total.7 commands ready, 2 candidates need your input, 1 already wrapped.Value Object coverage: 6%.
It reads model $fillable, $casts, and casts() declarations, plus any Schema::create blocks in your migrations. It never touches your database. Pass --json for machine-readable output, --no-cast to drop the cast suggestion from each command, or --path and --migrations-path to point at non-standard directories.
That coverage figure at the bottom is the part I find myself watching. It turns "we should really use Value Objects more" into a number you can actually move.
The Flags
make:value-object takes a handful of flags:
| Flag | Purpose |
|---|---|
--rule=NAME[:ARGS] |
Validation rule. One of email, url, ip, uuid, alpha_num, alpha, numeric, regex:PATTERN. |
--normalize=FN[,FN] |
Normalization. Compose with commas: lower, upper, trim. |
--type=PHP_TYPE |
Property type. Defaults to string. Also accepts int, float, bool, or a fully qualified class name. |
--method=NAME[:RETURN_TYPE] |
Adds an empty method stub. Repeatable. |
--cast=Model.column |
Wires the cast into app/Models/Model.php. Safe to re-run. |
--namespace=NS |
Overrides the configured default namespace. |
--no-test |
Skips the test stub. |
--dry-run |
Prints the files that would be written or changed without touching disk. |
--force |
Overwrites existing files. |
Requirements
- PHP 8.3+
- Laravel 13
Links
- GitHub: harris21/laravel-aegis
- Example repo: harris21/laravel-value-objects-examples
About the Name
In Greek mythology the aegis was Athena's shield, the thing she carried into battle to deflect what shouldn't reach what it protected. That's exactly what the constructor of a Value Object does: it accepts what's valid and refuses everything else.
The pattern goes back to Eric Evans' Domain-Driven Design and Martin Fowler's Patterns of Enterprise Application Architecture. None of that is new. Aegis just takes care of the typing, so you'll actually reach for it the next time a raw string starts causing trouble.