Supercharge Laravel’s validation with value objects
Laravel is a popular PHP framework that provides a robust and flexible validation system for handling user input and ensuring data integrity.
Laravel’s validation system allows you to define validation rules for incoming data and easily validate it against those rules.
The validation system in Laravel offers several features and benefits:
Convenient syntax: Laravel provides a fluent and expressive syntax for defining validation rules. You can specify the rules directly in your controller or form request classes.
Rule-based validation: You can define validation rules for various types of input data, such as strings, numbers, dates, emails, and more. Laravel offers a wide range of built-in validation rules that cover common use cases. For example, you can validate that a field is required, has a specific length, matches a regular expression, or exists in a database table.
Custom validation rules: Laravel allows you to define your own custom validation rules. This feature is handy when you have specific validation requirements unique to your application.
Error messages: When validation fails, Laravel automatically generates helpful error messages that you can display to the user. You can easily access these error messages and display them in your views.
Conditional validation: Laravel’s validation system supports conditional validation rules. You can specify that certain rules should only be applied if specific conditions are met. For example, you can validate a field as required only if another field has a certain value.
Form request validation: Laravel encourages the use of form request classes, which encapsulate the validation logic for a specific form or request. These classes centralize the validation rules, making your code more organized and maintainable.
Validation with database integration: Laravel allows you to validate input against your database records. You can perform unique rule validation to ensure that a value is unique in a specific table column.
Overall, Laravel’s validation system provides a convenient and powerful way to validate user input, enforce data integrity, and handle validation errors in a structured manner. It helps in improving the security, reliability, and user experience of your applications.
In programming, a value object is a concept from domain-driven design (DDD) that represents an immutable object whose equality is based on its attribute values rather than its identity. A value object’s main purpose is to encapsulate a set of related attributes or values into a single logical unit.
In PHP, value objects can be implemented as classes with private properties and getter methods, ensuring that the internal state cannot be modified once the object is created. Value objects typically have no identity and are compared based on their attribute values. This means that two value objects with the same attribute values are considered equal.
Here are some benefits of using value objects in PHP or any other programming language:
Clarity and expressiveness: By using value objects, you can give meaningful names to the data structures in your domain model. Value objects represent concepts and behaviors within your application, making the code more expressive and self-explanatory.
Immutability: Value objects are immutable, meaning their state cannot be changed after creation. This immutability guarantees that the data they hold remains consistent and cannot be accidentally modified. Immutability also simplifies reasoning about code since you don’t have to worry about unexpected changes to objects.
Encapsulation and data integrity: Value objects encapsulate related attributes, ensuring that the internal state is always valid and consistent. You can enforce business rules and constraints within the value object’s methods, ensuring that the object remains in a valid state.
Simplified comparisons: Value objects provide a natural way to compare objects based on their attribute values. You can easily check if two value objects are equal without having to compare individual attributes manually.
Domain-specific behavior: Value objects can encapsulate behavior related to the attributes they represent. By adding methods to value objects, you can define behavior specific to the domain concept they represent. This helps in keeping the behavior and the data it operates on closely related and self-contained.
Code reuse: By using value objects, you can reuse them across different parts of your application or even across multiple projects. Value objects are self-contained units of behavior and data, making them easily reusable and testable.
Strong typing and type safety: Value objects provide a way to create strongly typed data structures in dynamically typed languages like PHP. By defining value objects with specific attribute types, you can ensure that the data passed into and returned from your methods is of the correct type.
Overall, using value objects in PHP and other programming languages brings clarity, immutability, encapsulation, and domain-specific behavior to your code. They help in creating more expressive and maintainable code that accurately represents the concepts and constraints of your domain model.
Laravel’s validation system is flexible enough to handle various types of input, including value objects. Value objects can be used as inputs for validation rules, allowing you to validate and enforce rules specific to the attributes of the value object.
To use value objects in Laravel validation, you can follow these steps:
Create your value object: Define a class for your value object, encapsulating the related attributes and behavior. Ensure that the value object is immutable and implements appropriate methods for accessing its attribute values.
Prepare the input data: Before performing validation, you need to extract the attribute values from your value object and prepare them for validation. You can create an array or use Laravel’s
toArray()
method to convert the value object to an array representation.Define validation rules: In your validation rules, you can use the extracted attribute values from the value object as inputs. You can specify the rules directly in your controller or form request classes, similar to how you would validate other input data.
Perform validation: Use Laravel’s validation mechanism, such as the
Validator
class or form request validation, to perform the validation. Pass the extracted attribute values as the input data to be validated.
My data object represents a connection point:
<?php
namespace App\ValueObjects;
use Illuminate\Contracts\Support\Arrayable;
final class ConnectionPoint implements Arrayable {
const MAX_PORT_NUMBER = 288;
// Full format for a connection point: Z199S01P010
const FORMAT = "Z([0-9]{3})S([0-9]{2})P([0-9]{3})";
private function __construct(
private int $zone,
private int $stripe,
private int $port,
private string $name
)
{ }
The class implements the Arrayable
interface since we are connecting this with an Eloquent model. In the application, we will create a fresh instance of the model from time to time.
There are only two ways to instantiate our value object:
using a static conversion method to create a new connection point object
or creating an empty connection point object
public static function fromString(string $name = ''): self {
$parts = preg_match("/^".self::FORMAT."$/si", $name, $matches);
if ($parts === 0 || $parts === false) {
throw new \InvalidArgumentException(__("Invalid name argument has been passed: {$name}."));
}
list($name, $zone, $stripe, $port) = $matches;
// Additional checking can be added here if required
// I just removed mine, since it is not relevant for the topic
return new self($zone, $stripe, $port, $name);
}
public static function createEmpty(): self {
return new self(0, 0, 0, '');
}
The class is immutable and only allows getting out of data using getter methods and comparing objects to each other:
public function equals(ConnectionPoint $connectionPoint): bool {
return $this->hasSameName($connectionPoint) &&
$this->hasSameZone($connectionPoint) &&
$this->hasSameStripe($connectionPoint) &&
$this->hasSamePort($connectionPoint);
}
Now, let us get to the fun part. Let us connect our value object with an Eloquent model. For this Laravel provides a flexible casting system.
In the context of Laravel’s Eloquent ORM (Object-Relational Mapping), casting refers to the process of transforming the data retrieved from a database into native PHP data types and vice versa.
Eloquent allows you to define casts for specific attributes of your model, enabling you to work with attribute values consistently and conveniently.
To create a custom cast run the following artisan command:
php artisan make:cast ConnectionPointCast
The new cast class will be placed in your app/Casts
directory.
In our custom cast class, we define a getter, a setter and a compare method:
class ConnectionPointCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
$f = fn($value) => $value instanceof ConnectionPoint;
return match($value) {
$f($value) => $value,
null => ConnectionPoint::createEmpty(),
default => ConnectionPoint::fromString((string) $value)
};
}
public function set($model, string $key, $value, array $attributes)
{
// do not forget, that value objects are immutable!
if ($value instanceof ConnectionPoint) {
return $value->getName();
}
return (ConnectionPoint::fromString((string) $value))->getName();
}
public function compare($value, $originalValue)
{
$value = ConnectionPoint::fromString((string) $value);
$originalValue = ConnectionPoint::fromString((string) $originalValue);
return $value->equals($originalValue);
}
}
I use the compare
method to make sure that in the application when we call getDirty()
on our model then the various model instances are compared correctly.
As a final step in the Eloquent model, you have to define the custom cast by adding them to the $casts
property:
protected $casts = [
'start' => ConnectionPointCast::class,
'end' => ConnectionPointCast::class
];
Now, let us continue with Laravel validation. To make our value object work with Laravel, we need to create a custom validation object (you might use a closure as well as part of your validation, but if you are re-using these then it is simpler to put them into their class).
Use the make:rule
Artisan command to generate a new custom validation class. Specify the desired name for your custom rule. For example, let's create a rule called ConnectionPointCompare
:
php artisan make:rule ConnectionPointCompare --invokable
This will create our new rule in the app/Rules
directory of your project.
Our custom rule implements the DataAwareRule
and the ValidatorAwareRule
interfaces as well, since we need to get access to the validator and its data in our validation logic. You might, or might not need these in your code.
<?php
namespace App\Rules;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\InvokableRule;
use Illuminate\Contracts\Validation\ValidatorAwareRule;
use InvalidArgumentException;
use App\ValueObjects\ConnectionPoint;
class ConnectionPointCompare implements DataAwareRule, InvokableRule, ValidatorAwareRule
{
protected $data = [];
protected $validator;
public function __invoke($attribute, $value, $fail)
{
try {
$start = ConnectionPoint::fromString((string) $value);
try {
$end = ConnectionPoint::fromString((string) $this->data['end']);
if (!$start->compare($end))
$fail('validation.connection_point_compare')->translate();
} catch (InvalidArgumentException $e) {
$attributeName = __('validation.attributes.' . 'end');
$errorMessage = __('validation.connection_point_format', [
'attribute' => $attributeName,
'value' => $this->data['end']
]);
$this->validator->errors()->add('end', $errorMessage);
}
} catch (InvalidArgumentException $e) {
$fail('validation.connection_point_format')->translate([
'value' => $value,
]);
}
}
public function setData($data): self {
$this->data = $data;
return $this;
}
public function setValidator($validator): self {
$this->validator = $validator;
return $this;
}
}
You can see the power of data objects in the example code:
if we can not instantiate our start connection point then we immediately throw an exception with the relevant error message,
then we try to do the same for our end connection point. Please notice how we use the validator object and the validation data to access other attributes and their values,
if we were able to instantiate both connection points then we are ready to compare them.
compare()
is a method of our value object class that I’m not going to detail here,if everything works as expected then we are fine, otherwise, we throw the relevant exception.
By using value objects in Laravel validation, you can ensure that the input data adheres to the rules specific to the attributes of the value object, promoting data integrity and validation consistency within your application.
The final part is to integrate our custom validation rules into the validation process.
There are many way to use validation in Laravel. You can find the detailed documentation for your Laravel version here: https://laravel.com/docs/9.x/validation#custom-validation-rules
'start' => [
'bail',
'required',
new ConnectionPointFormat,
new ConnectionPointLocation,
new ConnectionPointCompare,
],
'end' => [
'bail',
'required',
new ConnectionPointFormat,
new ConnectionPointLocation,
],
As you can see we can chain our validation rules just like any other rule. I use the bail
keyword so after the first unsuccessful validation check the rest is not executed.
If I have to change the format of a connection point I have to do it in only one place in the entire application and everything else will still work without modification.
If I would like to change the way I compare these connection points, the validation system will follow ‘automagically’.
Value objects and custom validation provides an elegant and easy way to maintain clarity and expressiveness, encapsulation and data integrity and promotes code reuse throughout your Laravel application.
Just like I promised you at the beginning of this article.
Subscribe to my newsletter
Read articles from Peter Hrobar directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by