State Management
Validation, Form Objects, dirty tracking and type serialization for complex PHP types.
Validation
LiVue uses the #[Validate] attribute to declare Laravel validation rules directly on your component properties. When validation fails, errors are automatically available in both Blade and Vue templates.
Basic Usage
Apply the #[Validate] attribute to any public property. Rules follow the standard Laravel validation syntax:
<?php
namespace App\LiVue;
use LiVue\Attributes\Validate;
use LiVue\Component;
class ContactForm extends Component
{
#[Validate('required|min:3')]
public string $name = '';
#[Validate('required|email')]
public string $email = '';
#[Validate('required|min:10', as: 'message body')]
public string $message = '';
public function submit(): void
{
$validated = $this->validate();
// All validations passed, $validated contains the clean data
Contact::create($validated);
$this->redirect('/thank-you', navigate: true);
}
}
Rules as Arrays
You can pass rules as an array instead of a pipe-delimited string. You can also stack multiple #[Validate] attributes on the same property with different custom messages:
// Array syntax
#[Validate(['required', 'min:3', 'max:255'])]
public string $title = '';
// Stacked attributes with custom messages
#[Validate('required', message: 'The title is required.')]
#[Validate('min:5', message: 'The title must be at least 5 characters.')]
public string $title = '';
Custom Messages and Attribute Names
For more control, use the message and as parameters on the attribute, or override the messages() and validationAttributes() methods:
// Custom attribute name in error messages
#[Validate('required|email', as: 'email address')]
public string $email = '';
// Or via methods for more complex scenarios
protected function messages(): array
{
return [
'email.required' => 'We need your email address.',
'email.email' => 'That doesn\'t look like a valid email.',
];
}
protected function validationAttributes(): array
{
return [
'email' => 'email address',
'name' => 'full name',
];
}
Dynamic Rules with rules()
For rules that depend on runtime state or require Laravel Rule objects, override the rules() method. Rules defined here take precedence over attribute-based rules:
use Illuminate\Validation\Rule;
protected function rules(): array
{
return [
'email' => [
'required',
'email',
Rule::unique('users')->ignore($this->userId),
],
];
}
The validate() Method
Call $this->validate() to validate all properties that have rules. If validation passes, it returns the validated data. If it fails, a ValidationException is thrown and caught automatically by LiVue, which populates the error bag and re-renders the component.
public function submit(): void
{
$validated = $this->validate();
// Validation passed — process the data
Contact::create($validated);
}
// You can also pass inline rules to override
$validated = $this->validate(
rules: ['name' => 'required|min:3'],
messages: ['name.required' => 'Name is required!'],
);
Tip — You can also manage errors manually with $this->addError('field', 'message'), $this->resetValidation(), and $this->resetValidation('field').
Displaying Errors
LiVue provides two approaches for showing validation errors in your templates. You can use either one or both simultaneously.
1. Blade @error Directive
The @error directive works because LiVue shares the error bag with Blade before re-rendering the template on the server:
<div>
<label class="block text-sm text-gray-400 mb-1">Name</label>
<input type="text" v-model="name">
@error('name')
<span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
</div>
2. Vue $errors Helper
The $errors magic variable is a reactive proxy available in templates. Each key returns the first error message string directly (not an array):
<div>
<label class="block text-sm text-gray-400 mb-1">Name</label>
<input type="text" v-model="name">
<span
v-if="$errors.name"
class="text-red-500 text-sm"
v-text="$errors.name"
></span>
</div>
The $errors object provides the following API:
| Expression | Description |
|---|---|
$errors.field |
First error message string for the field, or undefined if no errors |
livue.clearErrors() |
Clear all errors from the client |
Combining Both Approaches
Both approaches work simultaneously. The Blade @error directive renders server-side, while $errors updates reactively on the client:
<input type="text" v-model="name">
<!-- Server-rendered (Blade) -->
@error('name')
<span class="text-red-500">{{ $message }}</span>
@enderror
<!-- Client-rendered (Vue) -->
<span v-if="$errors.name" class="text-red-500" v-text="$errors.name"></span>
Note — When using both approaches together, you may see duplicate error messages. In most cases, choose one approach per field and use it consistently throughout your template.
Real-time Validation
Use validateOnly('propertyName') to validate a single field without affecting the error state of other fields. This is ideal for live validation as the user fills out a form.
Server-side with Updated Hooks
Call validateOnly() inside an updated hook to validate the field whenever it changes:
class RegisterForm extends Component
{
#[Validate('required|min:3')]
public string $name = '';
#[Validate('required|email|unique:users')]
public string $email = '';
#[Validate('required|min:8')]
public string $password = '';
public function updatedName(): void
{
$this->validateOnly('name');
}
public function updatedEmail(): void
{
$this->validateOnly('email');
}
public function updatedPassword(): void
{
$this->validateOnly('password');
}
public function register(): void
{
$validated = $this->validate();
User::create($validated);
}
}
Template with Blur Validation
Combine v-model.blur or v-model.debounce to control when validation is triggered. Using v-model.blur sends the value to the server when the user leaves the field, which in turn fires the updated hook:
<form @submit.prevent="register()">
<div class="mb-4">
<label>Name</label>
<input type="text" v-model.blur="name">
<span v-if="$errors.name" class="text-red-500 text-sm">
{{ $errors.name }}
</span>
</div>
<div class="mb-4">
<label>Email</label>
<input type="email" v-model.blur="email">
<span v-if="$errors.email" class="text-red-500 text-sm">
{{ $errors.email }}
</span>
</div>
<div class="mb-4">
<label>Password</label>
<input type="password" v-model.blur="password">
<span v-if="$errors.password" class="text-red-500 text-sm">
{{ $errors.password }}
</span>
</div>
<button type="submit" :disabled="livue.loading">
<span v-if="livue.processing === 'register'">Registering...</span>
<span v-else>Register</span>
</button>
</form>
Tip — Use v-model.debounce.500ms for fields where you want validation while the user types, with a 500ms delay to avoid excessive server calls.
Form Objects
Form Objects let you extract form fields, validation rules and submission logic into dedicated classes. This keeps your components clean and makes forms reusable across different components.
Creating a Form Object
Generate a Form Object with the Artisan command:
php artisan make:livue-form Contact
# Creates: app/LiVue/Forms/ContactForm.php
The Form class extends LiVue\Form and contains public properties with validation attributes:
<?php
namespace App\LiVue\Forms;
use LiVue\Attributes\Validate;
use LiVue\Form;
class ContactForm extends Form
{
#[Validate('required|min:2')]
public string $name = '';
#[Validate('required|email')]
public string $email = '';
#[Validate('required|min:10')]
public string $message = '';
public string $subject = 'General';
}
Using in a Component
Declare the Form Object as a typed public property in your component. Initialize it in the constructor:
use App\LiVue\Forms\ContactForm;
class ContactPage extends Component
{
public ContactForm $form;
public function __construct()
{
parent::__construct();
$this->form = new ContactForm();
}
public function submit(): void
{
$data = $this->form->validate();
Mail::to('admin@example.com')
->send(new ContactMail($data));
$this->form->reset();
}
}
<form @submit.prevent="submit()">
<div>
<input v-model="form.name">
<span v-if="$errors['form.name']">
{{ $errors['form.name'] }}
</span>
</div>
<div>
<input v-model="form.email">
<span v-if="$errors['form.email']">
{{ $errors['form.email'] }}
</span>
</div>
<div>
<textarea v-model="form.message"></textarea>
<span v-if="$errors['form.message']">
{{ $errors['form.message'] }}
</span>
</div>
<button type="submit">Send</button>
</form>
Note — When using Form Objects, errors are prefixed with the property name. For a form declared as $form, access errors via $errors['form.name'], not $errors.name.
Available Methods
| Method | Description |
|---|---|
$form->fill($data) |
Populate fields from an array or Eloquent model |
$form->all() |
Get all public fields as an array |
$form->only(['name', 'email']) |
Get only the specified fields |
$form->except(['subject']) |
Get all fields except the specified ones |
$form->pull() |
Get data and reset the form in one call |
$form->reset() |
Reset all fields to their default values |
$form->validate() |
Validate all fields and return validated data |
$form->validateOnly('email') |
Validate a single field |
Create/Edit Pattern
A single Form Object can handle both creating and editing resources:
class PostForm extends Form
{
public ?Post $post = null;
#[Validate('required|min:5')]
public string $title = '';
#[Validate('required')]
public string $content = '';
public function setPost(Post $post): static
{
$this->post = $post;
$this->fill($post);
return $this;
}
public function save(): Post
{
$validated = $this->validate();
if ($this->post) {
$this->post->update($validated);
return $this->post;
}
$post = Post::create($validated);
$this->reset();
return $post;
}
}
Tip — Use $form->fill($model) in the component's mount() method to pre-populate fields when editing an existing resource.
Dirty Tracking
LiVue tracks which properties have been modified compared to the last server-confirmed state. This lets you enable save buttons only when changes exist, highlight modified fields, or skip unnecessary server calls.
Server-side: WithDirtyTracking Trait
Add the WithDirtyTracking trait to your component to enable server-side dirty tracking:
use LiVue\Features\SupportDirtyTracking\WithDirtyTracking;
class EditProfile extends Component
{
use WithDirtyTracking;
public string $name = '';
public string $email = '';
public string $bio = '';
public function mount(): void
{
$user = auth()->user();
$this->name = $user->name;
$this->email = $user->email;
$this->bio = $user->bio;
}
public function save(): void
{
if (!$this->isDirty()) {
return; // Nothing to save
}
if ($this->isDirty('email')) {
// Email changed, require re-verification
$this->sendEmailVerification();
}
// Only update the changed fields
auth()->user()->update($this->getDirty());
}
}
The trait provides these methods:
| Method | Description |
|---|---|
$this->isDirty() |
Returns true if any property has been modified |
$this->isDirty('name') |
Returns true if the specified property has been modified |
$this->isClean() |
Returns true if no properties have been modified |
$this->getDirty() |
Returns an array of modified properties [key => newValue] |
$this->getOriginal('name') |
Returns the original value of a property before modifications |
Client-side: livue API
Dirty tracking is also available in your templates through the livue object. The client compares the current reactive state with the last server-confirmed state:
<form @submit.prevent="save()">
<div class="mb-4">
<label>Name</label>
<input
v-model="name"
:class="{ 'border-yellow-500': livue.dirtyFields.has('name') }"
>
</div>
<div class="mb-4">
<label>Email</label>
<input
v-model="email"
:class="{ 'border-yellow-500': livue.dirtyFields.has('email') }"
>
<p v-if="livue.isDirty('email')" class="text-sm text-gray-500">
Original: {{ livue.getOriginal('email') }}
</p>
</div>
<!-- Show save button only when there are changes -->
<div class="flex gap-2">
<button
type="submit"
:disabled="!livue.isDirty() || livue.loading"
>
Save Changes
</button>
<button
type="button"
@click="livue.resetAll()"
v-if="livue.isDirty()"
>
Discard
</button>
</div>
<!-- Unsaved changes warning -->
<div v-if="livue.isDirty()" class="mt-4 p-3 bg-yellow-500/10 text-yellow-400 rounded">
You have unsaved changes.
</div>
</form>
The v-dirty Directive
For a more declarative approach, use the v-dirty directive to toggle visibility, add classes, or set attributes based on dirty state:
<!-- Show when any property is dirty (hidden by default) -->
<div v-dirty>You have unsaved changes</div>
<!-- Show when a specific property is dirty -->
<span v-dirty:email>Email modified</span>
<!-- Add class when dirty -->
<input v-model="name" v-dirty:name.class="'border-yellow-500'">
<!-- Remove class when dirty -->
<input v-model="bio" v-dirty:bio.class.remove="'border-gray-300'">
<!-- Add attribute when dirty -->
<button v-dirty.attr="'data-modified'">Save</button>
| Modifier | Behavior |
|---|---|
(default) |
Toggle visibility: hidden when clean, visible when dirty |
.class |
Add the specified class(es) when dirty |
.class.remove |
Remove the specified class(es) when dirty |
.attr |
Add the specified attribute when dirty |
Tip — Client-side dirty tracking is independent from server-side tracking. The client compares Vue state against the last server response, while the server compares against the state at the beginning of the request.
Synthesizers
PHP types like Eloquent Models, Carbon dates, Enums and Collections cannot be represented directly in JSON. The PropertySynthesizer system handles the conversion between complex PHP objects and JSON-safe representations, ensuring these types survive the PHP-to-JavaScript-to-PHP round trip.
Inline Tuples
Synthesized values are stored as inline tuples in the snapshot state. A tuple is a two-element array: the serialized value and a metadata object with at least an s key identifying the synthesizer. Scalar types (string, int, float, bool, null) pass through without transformation.
{
"state": {
"count": 5, // scalar — no tuple
"item": [
{"id": 1, "title": "Buy milk"}, // serialized value
{"s": "mdl", "class": "App\\Models\\ToDo", "key": 1} // metadata
],
"status": ["pending", {"s": "enm", "class": "App\\Enums\\TodoStatus"}],
"dueDate": ["2025-01-27T10:00:00+00:00", {"s": "crb"}],
"tags": [["urgent", "test"], {"s": "clc"}]
}
}
Built-in Synthesizers
LiVue ships with four synthesizers that cover the most common PHP types:
EloquentModelSynth s: "mdl"
Serializes Eloquent models. On dehydration, sends the model's attributes as JSON. On hydration, the model is always re-fetched from the database via findOrFail($key) — never reconstructed from client data. Diffs from v-model are applied via fill(), respecting $fillable.
CarbonSynth s: "crb"
Handles Carbon and CarbonImmutable dates. Serializes to ISO 8601 string and reconstructs via Carbon::parse().
EnumSynth s: "enm"
Serializes PHP BackedEnum instances. Stores the enum's value and FQCN, then reconstructs via EnumClass::from($value).
CollectionSynth s: "clc"
Handles Laravel Collections. Recursively processes each item, so a Collection of Models or Enums is fully supported.
Using Synthesized Types
No special configuration is required. Simply declare the typed property and LiVue handles the rest:
use App\Models\ToDo;
use App\Enums\TodoStatus;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class TodoList extends Component
{
public ToDo $item; // Eloquent Model
public TodoStatus $status; // BackedEnum
public Carbon $dueDate; // Carbon date
public Collection $tags; // Collection
}
Custom Synthesizers
You can create custom synthesizers for your own value objects by extending PropertySynthesizer:
namespace App\Synthesizers;
use LiVue\Synthesizers\PropertySynthesizer;
use App\ValueObjects\Money;
class MoneySynth extends PropertySynthesizer
{
public function key(): string
{
return 'mny';
}
public function match(mixed $value): bool
{
return $value instanceof Money;
}
public function dehydrate(mixed $value, string $property): array
{
return [
['amount' => $value->amount, 'currency' => $value->currency],
['s' => 'mny'],
];
}
public function hydrate(mixed $value, array $meta, string $property): mixed
{
return new Money($value['amount'], $value['currency']);
}
}
Register your custom synthesizer in a Service Provider's boot() method:
use LiVue\Facades\LiVue;
public function boot(): void
{
LiVue::registerSynthesizer(\App\Synthesizers\MoneySynth::class);
}
Note — The entire snapshot state, including synthesizer metadata, is protected by an HMAC checksum. Any attempt to tamper with the metadata (e.g., changing a model's class) will invalidate the checksum and the request will be rejected.