v1 LiVue v1 is here — server-driven reactivity for Laravel using Vue.js Get Started →

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:

app/LiVue/ContactForm.php
<?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:

Blade Template
<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):

Vue Template
<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:

app/LiVue/RegisterForm.php
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:

resources/views/livue/register-form.blade.php
<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:

app/LiVue/Forms/ContactForm.php
<?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:

Component
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();
    }
}
Template
<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:

app/LiVue/Forms/PostForm.php
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:

app/LiVue/EditProfile.php
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:

Template
<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.

Snapshot state example
{
    "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:

app/LiVue/TodoList.php
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:

app/Synthesizers/MoneySynth.php
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.