Stop passing primitives around: Laravel value objects in practice
A float $distance looks harmless until someone passes -10.
Not maliciously. Not even carelessly, necessarily. It might come from a skipped validation path, an internal job, a test fixture, a refactor, or a controller that grew one extra branch after the original rules were written.
Then that -10 drifts through the app. A service accepts it. A query builder uses it. A calculation converts it. Eventually the output looks wrong, and the question becomes: where did this impossible value come from?
That’s the bit I care about with value objects. Not the DDD vocabulary. Not the feeling of architecture cosplay. A small value object can stop an impossible value from existing in your application in the first place.
The primitive problem
This is easy to write in Laravel:
final readonly class NearbySearch
{
public function __construct(
private PlaceRepository $places,
) {}
public function handle(float $latitude, float $longitude, float $distance): Collection
{
return $this->places->withinDistance(
latitude: $latitude,
longitude: $longitude,
distance: $distance,
);
}
}
It’s also easy to misuse:
$search->handle(
latitude: 51.4816,
longitude: -3.1791,
distance: -10,
);
PHP did what we asked. -10 is a valid float. It is not a valid distance.
That distinction matters. A type can be technically correct and still be nonsense in your domain.
Validation helps at the edge
In Laravel, I still want request validation. If a user submits a search radius, the HTTP boundary should reject bad input before application code sees it. A Form Request with numeric and min:0 is completely reasonable, and Laravel documents min as applying to numeric values as well as strings, arrays, and files (Laravel validation docs).
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
final class StoreNearbySearchRequest extends FormRequest
{
public function rules(): array
{
return [
'latitude' => ['required', 'numeric'],
'longitude' => ['required', 'numeric'],
'distance_miles' => ['required', 'numeric', 'min:0'],
];
}
}
The problem is that validation is usually tied to one entry point.
Your application has more than one entry point.
Controllers call services. Jobs call services. Commands call services. Tests call services. Scheduled tasks call services. Another developer might add an endpoint later and forget the same rule. Once your service accepts float $distance, every caller has to remember what that float means and which values are allowed.
That is a lot of trust to place in a primitive.
Promote the distance into a value object
Here’s the small version I’d start with in PHP 8.2+ using a readonly class. PHP 8.2 introduced readonly classes, which make every declared instance property readonly and fit nicely for immutable value objects (PHP 8.2 release notes).
<?php
namespace App\ValueObjects;
use InvalidArgumentException;
final readonly class Distance
{
private function __construct(
private float $metres,
) {
if ($metres < 0) {
throw new InvalidArgumentException('Distance cannot be negative.');
}
}
public static function fromMetres(float $metres): self
{
return new self($metres);
}
public static function fromKilometres(float $kilometres): self
{
return new self($kilometres * 1000);
}
public static function fromMiles(float $miles): self
{
return new self($miles * 1609.344);
}
public function toMetres(): float
{
return $this->metres;
}
public function toKilometres(): float
{
return $this->metres / 1000;
}
public function toMiles(): float
{
return $this->metres / 1609.344;
}
}
Now the constructor owns the invariant: a distance cannot be negative.
The private constructor is doing useful work too. You can’t create a Distance by throwing a raw number at new Distance(...). You have to say what unit the number is in:
$radius = Distance::fromMiles(10);
That might feel like a tiny thing. It is. Tiny things are allowed to be useful.
Pass the value object through the app
Once the raw request has been validated, convert it once and pass the value object onwards.
use App\ValueObjects\Distance;
final class NearbySearchController
{
public function store(StoreNearbySearchRequest $request, NearbySearch $search): JsonResponse
{
$results = $search->handle(
latitude: $request->float('latitude'),
longitude: $request->float('longitude'),
radius: Distance::fromMiles($request->float('distance_miles')),
);
return response()->json($results);
}
}
The service now asks for what it actually needs:
use App\ValueObjects\Distance;
final readonly class NearbySearch
{
public function __construct(
private PlaceRepository $places,
) {}
public function handle(float $latitude, float $longitude, Distance $radius): Collection
{
return $this->places->withinDistance(
latitude: $latitude,
longitude: $longitude,
radiusMetres: $radius->toMetres(),
);
}
}
This does not remove the need for validation. It changes what happens after validation.
The rest of the app no longer receives a number that might be miles, kilometres, metres, or nonsense. It receives a Distance.
Give the business logic a home
This is the main reason I keep reaching for value objects.
Validation is useful, but the bigger win is that behaviour now has somewhere obvious to live. If you need miles for display, metres for storage, and kilometres for an external API, you don’t scatter conversion maths across controllers, resources, services, and queued jobs.
You put it beside the value:
$radius = Distance::fromMiles(10);
$radius->toMetres(); // 16093.44
$radius->toKilometres(); // 16.09344
$radius->toMiles(); // 10
On another Laravel project, I’ve been using this pattern extensively for measurements: distances, speeds, depths, percentages, temperatures, and angles. The production version is a little more careful than the example above. It uses precise number handling, canonical internal units, named factories, comparisons, JSON output, and tighter domain-specific validation where needed.
But the principle is the same: the value object protects the invariant and owns the operations that make sense for that value.
That last part is important. A distance can be converted. A distance can be compared with another distance. A distance can be formatted for an API. A distance cannot be negative. Those rules belong together.
Persisting it with an Eloquent cast
Laravel also lets you wire value objects into Eloquent. The docs show custom value-object casts and note that a value object can implement Castable so the object defines its own caster (Laravel Eloquent mutators and casting docs).
For a small value object, I quite like using an anonymous cast class from castUsing(). Laravel documents this as a way to keep the value object and its casting logic together (anonymous cast classes).
<?php
namespace App\ValueObjects;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
final readonly class Distance implements Castable
{
private function __construct(
private float $metres,
) {
if ($metres < 0) {
throw new InvalidArgumentException('Distance cannot be negative.');
}
}
public static function fromMetres(float $metres): self
{
return new self($metres);
}
public function toMetres(): float
{
return $this->metres;
}
public static function castUsing(array $arguments): CastsAttributes
{
return new class implements CastsAttributes
{
public function get(
Model $model,
string $key,
mixed $value,
array $attributes,
): Distance {
return Distance::fromMetres((float) $value);
}
public function set(
Model $model,
string $key,
mixed $value,
array $attributes,
): float {
if (! $value instanceof Distance) {
throw new InvalidArgumentException('The given value is not a Distance instance.');
}
return $value->toMetres();
}
};
}
}
Then the model can cast the column directly:
<?php
namespace App\Models;
use App\ValueObjects\Distance;
use Illuminate\Database\Eloquent\Model;
final class SearchArea extends Model
{
protected function casts(): array
{
return [
'radius_metres' => Distance::class,
];
}
}
Now this is a Distance when you read it:
$area = SearchArea::find(1);
$area->radius_metres->toMiles();
And this stores the canonical metres value when you write it:
$area->radius_metres = Distance::fromMetres(5000);
$area->save();
There is a trade-off. Inline anonymous casts are tidy when persistence is simple and intrinsic to the value object. If the cast starts dealing with multiple columns, legacy formats, encryption, tenancy, or awkward null behaviour, I’d move it into a named cast class. Keeping things together is only helpful while the thing remains readable.
What value objects do not solve
Value objects are not a replacement for every other guardrail.
I still want request validation because users deserve useful error messages. I still want database constraints where the database can enforce something cheaply. I still want tests around business rules. I still want static analysis where it helps.
The value object has a narrower job: make invalid application states harder to represent.
It also should not become a reflex. I would not wrap every string, integer, and boolean just because I can. That gets tiring quickly, and tired developers route around patterns.
I reach for a value object when at least one of these is true:
- invalid values are easy to create with a primitive;
- the value has a unit;
- conversion logic appears in more than one place;
- comparisons need domain rules;
- the name of the primitive is doing too much work;
- a bug would be expensive or embarrassing.
Distance qualifies. Money usually qualifies. Date ranges often qualify. Some random admin setting probably does not.
Wrap-up
Value objects are not ceremony by default. Bad value objects can become ceremony, certainly. So can bad services, bad DTOs, bad traits, and bad anything else we invent on a Tuesday afternoon.
Used carefully, a value object is just a small boundary. It says: once this value is inside the application, it is valid, named, and capable of the operations the domain expects from it.
For Distance, that means no negatives, no mystery units, and no repeated conversion maths sprinkled around the codebase.