Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 049 / 100

Livewire for interactive UIs in PHP & Laravel Development

Dynamic interfaces in PHP.

Expona Admin7 min read

Livewire for interactive UIs in PHP & Laravel Development

Building modern, responsive interfaces in PHP has traditionally meant either switching to JavaScript frameworks or writing verbose AJAX boilerplate. Livewire changes that equation entirely by letting you build dynamic, reactive UIs without leaving PHP. In this guide, we'll explore how Livewire for interactive UIs in PHP & Laravel development transforms stateful component logic, real-time validation, and complex features like pagination into first-class Laravel citizens.

Why Livewire Matters for Your Team

If you've spent time building SPAs with React or Vue, you know the productivity tax: separate frontend and backend, API versioning headaches, and context switching between languages. Livewire collapses that distance. Your component is both the view and the state machine. Actions fire directly from the UI, update PHP properties, and re-render without a page refresh. For teams already deep in Laravel, this is a natural productivity multiplier.

That said, Livewire isn't a complete JavaScript replacement. It's best suited for medium-density interactions within traditional server-rendered pages—dashboards, admin panels, real-time forms, and live search. If you're building a full-screen canvas app or something that needs sub-100ms interaction latency, you'll still reach for a JavaScript framework.

Components with State and Actions

A Livewire component is a PHP class that holds reactive properties. When you interact with the UI, methods on that class execute, properties update, and the view re-renders. This model is surprisingly powerful for form-heavy interfaces.

Let's build a simple product filter for an e-commerce listing:

php
<?php

namespace App\Livewire;

use App\Models\Product;
use Livewire\Component;
use Livewire\Attributes\Url;

class ProductFilter extends Component
{
    #[Url]
    public string $category = '';
    
    #[Url]
    public string $search = '';
    
    #[Url]
    public float $maxPrice = 1000;

    public function updatedSearch($value)
    {
        $this->search = trim($value);
    }

    public function resetFilters()
    {
        $this->category = '';
        $this->search = '';
        $this->maxPrice = 1000;
    }

    public function render()
    {
        $products = Product::query()
            ->when($this->category, fn($q) => $q->whereCategory($this->category))
            ->when($this->search, fn($q) => $q->where('name', 'like', "%{$this->search}%"))
            ->where('price', '<=', $this->maxPrice)
            ->get();

        return view('livewire.product-filter', [
            'products' => $products,
        ]);
    }
}

The view glues it together:

php
<div class="filter-panel">
    <input 
        type="text" 
        wire:model.live="search" 
        placeholder="Search products"
    >
    
    <select wire:model.live="category">
        <option value="">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="books">Books</option>
    </select>
    
    <input 
        type="range" 
        wire:model.live="maxPrice" 
        min="0" 
        max="5000"
    >
    <span>Up to ${{ number_format($maxPrice, 2) }}</span>
    
    <button wire:click="resetFilters">Reset</button>
    
    <div class="results">
        @foreach($products as $product)
            <div class="product-card">
                <h3>{{ $product->name }}</h3>
                <p>{{ $product->price }}</p>
            </div>
        @endforeach
    </div>
</div>

Notice the wire:model.live directive. It binds the input to the component property and triggers an update on every keystroke or change. The #[Url] attribute keeps filters in the URL, so users can share filtered results and browser history works as expected.

The updatedSearch hook runs whenever $search changes, letting you intercept and transform the value. Similar hooks exist for every property: updated{PropertyName} or the more explicit #[On] attribute with event listeners.

Real-Time Validation

Validation in Livewire is blissfully simple. Validate on blur, on submit, or as the user types—the choice is yours. No separate API endpoint needed.

Consider an invitation form for a CRM:

php
<?php

namespace App\Livewire;

use Livewire\Component;

class InviteUser extends Component
{
    public string $email = '';
    public string $role = 'viewer';
    public array $errors = [];

    public function updatedEmail($value)
    {
        $this->validateOnly('email', [
            'email' => 'required|email|unique:users',
        ]);
    }

    public function invite()
    {
        $validated = $this->validate([
            'email' => 'required|email|unique:users',
            'role' => 'required|in:viewer,editor,admin',
        ]);

        Invitation::create($validated);
        
        $this->reset();
        $this->dispatch('invitation-sent');
    }

    public function render()
    {
        return view('livewire.invite-user');
    }
}

The view:

php
<form wire:submit="invite">
    <div>
        <label for="email">Email</label>
        <input 
            id="email"
            type="email" 
            wire:model.blur="email"
            class="@error('email') border-red-500 @enderror"
        >
        @error('email')
            <span class="text-red-500">{{ $message }}</span>
        @enderror
    </div>

    <div>
        <label for="role">Role</label>
        <select id="role" wire:model="role">
            <option value="viewer">Viewer</option>
            <option value="editor">Editor</option>
            <option value="admin">Admin</option>
        </select>
    </div>

    <button type="submit">Send Invitation</button>
</form>

The wire:model.blur directive validates only when the field loses focus, reducing noise. validateOnly validates a single field without blocking the entire form. On successful submit, $this->reset() clears the form and we dispatch an event to notify the rest of the page.

Pagination and File Uploads Built In

Livewire ships with pagination as a first-class feature. No API routes, no JavaScript trickery—just standard Laravel pagination on a component.

A simple paginated list:

php
<?php

namespace App\Livewire;

use App\Models\Invoice;
use Livewire\Component;
use Livewire\WithPagination;

class InvoiceTable extends Component
{
    use WithPagination;

    public string $sortBy = 'created_at';
    public string $direction = 'desc';
    public string $search = '';

    public function sortBy($column)
    {
        if ($this->sortBy === $column) {
            $this->direction = $this->direction === 'asc' ? 'desc' : 'asc';
        } else {
            $this->sortBy = $column;
            $this->direction = 'asc';
        }
    }

    public function render()
    {
        $invoices = Invoice::query()
            ->when($this->search, fn($q) => 
                $q->where('number', 'like', "%{$this->search}%")
            )
            ->orderBy($this->sortBy, $this->direction)
            ->paginate(15);

        return view('livewire.invoice-table', [
            'invoices' => $invoices,
        ]);
    }
}

File uploads in Livewire are handled via the WithFileUploads trait and temporary storage:

php
<?php

namespace App\Livewire;

use Livewire\Component;
use Livewire\WithFileUploads;

class DocumentUploader extends Component
{
    use WithFileUploads;

    public $document;

    public function uploadDocument()
    {
        $this->validate([
            'document' => 'required|file|mimes:pdf,doc,docx|max:10240',
        ]);

        $path = $this->document->store('documents', 'private');

        Document::create([
            'path' => $path,
            'original_name' => $this->document->getClientOriginalName(),
        ]);

        $this->reset('document');
        $this->dispatch('document-uploaded');
    }

    public function render()
    {
        return view('livewire.document-uploader');
    }
}

In the view, you bind the file input to the component property. Livewire handles chunked uploads and temporary cleanup automatically:

php
<form wire:submit="uploadDocument">
    <input type="file" wire:model="document">
    
    @error('document')
        <span class="text-red-500">{{ $message }}</span>
    @enderror
    
    <button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Upload</span>
        <span wire:loading>Uploading...</span>
    </button>
</form>

Pairing with Alpine.js for Small Client Behavior

Not every interaction needs to round-trip to the server. Toggle a dropdown, show/hide a modal, or debounce a search input—these are perfect candidates for Alpine.js, Livewire's lightweight companion.

A modal overlay with Alpine and Livewire:

php
<div 
    x-data="{ open: false }" 
    class="modal-container"
>
    <button @click="open = true" wire:click="loadDetails">
        View Details
    </button>

    <div 
        x-show="open" 
        @click.outside="open = false"
        class="modal-overlay"
    >
        <div class="modal-content">
            <h3>{{ $title }}</h3>
            <p>{{ $description }}</p>
            <button @click="open = false">Close</button>
        </div>
    </div>
</div>

Alpine manages the UI state (modal is open or closed), while Livewire fetches the actual data. This division of labor keeps both lightweight: Alpine is ~15KB, and you avoid unnecessary server calls.

For real-time search with debouncing:

php
<input 
    type="text"
    x-data
    @input.debounce.500ms="wire:search($event.target.value)"
    placeholder="Search..."
>

Alpine debounces the input, firing Livewire's search method only after the user pauses for 500ms. This reduces server load and feels snappier.

Common Mistakes

Querying too much data. Livewire re-renders the entire component on every state change. If your render() method queries 10,000 rows without pagination, you'll bottleneck the server. Always paginate large datasets and use eager loading to reduce N+1 queries.

Ignoring wire:key on loops. When iterating over a list, always add wire:key to help Livewire track which element is which. Without it, state can leak between list items.

Mixing too much Alpine. If you find yourself writing complex Alpine logic to coordinate with Livewire, you've probably hit Livewire's ceiling. Consider a JavaScript framework or break the component into smaller Livewire pieces.

Forgetting to validate on the server. Client-side validation and Livewire's validateOnly are nice UX, but always run your full validation on the business logic side. Never trust the client, even when it's Livewire.

Key Takeaways

  • Livewire components are PHP classes with reactive properties and methods, eliminating the need for separate API endpoints and JavaScript frameworks for medium-density interactions.
  • Use wire:model.live for real-time binding and validateOnly to validate individual fields as users type, providing immediate feedback without full-form submission.
  • Pagination and file uploads are built into Livewire; use WithPagination and WithFileUploads traits to handle these common patterns with minimal code.
  • Pair Livewire with Alpine.js for lightweight client-side interactions (modals, dropdowns, debouncing) to keep server load low and response times snappy.
  • Always query with pagination in render(), use wire:key in loops, and validate on the server side even though Livewire makes client-side validation convenient.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles