DEV Community

Cover image for Livewire 4 Single-File Components: Build a Live Search in One File
Hafiz
Hafiz

Posted on • Originally published at hafiz.dev

Livewire 4 Single-File Components: Build a Live Search in One File

Originally published at hafiz.dev


If you ran php artisan make:livewire after upgrading to Livewire v4 and noticed the file landed in resources/views/components/ instead of app/Livewire/, that wasn't a mistake. That's the new default. Livewire 4 shipped single-file components as the standard format in January 2026, and most Livewire developers are still building with the old two-file pattern out of habit.

This tutorial covers how single-file components actually work, when to use them versus the multi-file format, what's changed about scoped CSS and JavaScript in v4, and how to build something real with it: a live search component with filtering, scoped styles, and an Island for expensive data. No shortcuts , just the format you'll be using from now on.

The Problem With the Old Two-File Pattern

In Livewire v3, creating a component meant two files minimum. The PHP class lived in app/Livewire/SearchPosts.php, and the Blade view lived in resources/views/livewire/search-posts.blade.php. If you wanted component-specific JavaScript, you'd reach for @script or @push('scripts'). CSS was either inline or pushed to a stack.

It worked. But every time you built a component, you mentally held two files together. When you searched for a component in your editor, you got two results. When you read a diff, the logic and the template were on separate lines of the PR. The connection between them existed only in your head and in render().

Livewire 4 puts everything in one file. PHP class, Blade template, scoped CSS, component JavaScript. One file, one search result, one diff chunk. That's the entire point.

What a Single-File Component Looks Like

Create one with:

php artisan make:livewire search-posts
Enter fullscreen mode Exit fullscreen mode

This generates a file at resources/views/components/⚡search-posts.blade.php. The structure is:

<?php

use Livewire\Component;
use Livewire\Attributes\Computed;
use App\Models\Post;

new class extends Component {

    public string $query = '';

    #[Computed]
    public function results()
    {
        if (strlen($this->query) < 2) {
            return collect();
        }

        return Post::where('title', 'like', "%{$this->query}%")
            ->limit(10)
            ->get();
    }

};

?>

> **[View the interactive component on hafiz.dev](https://hafiz.dev/blog/livewire-4-single-file-components-tutorial)**
Enter fullscreen mode Exit fullscreen mode

Both the <style> and <script> blocks are served as native .css and .js files with browser caching. Livewire handles the bundling. You don't touch Vite config or webpack.mix.js to make this work.

One thing to flag: the bare <script> tag works in single-file and multi-file components. If you're still on class-based components where the Blade view is separate from the PHP class, you need @script instead. That's the v3 pattern and it still works, but it's no longer the default.

Adding an Island for Expensive Data

Our search component runs a database query on every keystroke (debounced to 300ms). That's fine for a simple search. But what if the component also needs to show some stats , total posts, most searched terms , that are expensive to compute and don't change with every search?

That's where Islands come in. They let you mark a region of your component to update independently from the rest:

<?php

use Livewire\Component;
use Livewire\Attributes\Computed;
use App\Models\Post;

new class extends Component {

    public string $query = '';

    #[Computed]
    public function results()
    {
        if (strlen($this->query) < 2) {
            return collect();
        }

        return Post::where('title', 'like', "%{$this->query}%")
            ->limit(10)
            ->get();
    }

    #[Computed]
    public function stats()
    {
        return [
            'total' => Post::count(),
            'published' => Post::where('status', 'published')->count(),
        ];
    }

};

?>

<div>
    <input
        wire:model.live.debounce.300ms="query"
        type="search"
        placeholder="Search posts..."
        class="search-input"
    />

    @if($this->results->isNotEmpty())
        <ul class="results-list">
            @foreach($this->results as $post)
                <li>
                    <a href="{{ route('posts.show', $post) }}">
                        {{ $post->title }}
                    </a>
                </li>
            @endforeach
        </ul>
    @endif

    @island(name: 'stats', lazy: true)
        <div class="stats">
            <span>{{ $this->stats['total'] }} total posts</span>
            <span>{{ $this->stats['published'] }} published</span>
        </div>
    @endisland
</div>
Enter fullscreen mode Exit fullscreen mode

When the user types, only the search results re-render. The stats island loads lazily and stays cached until you explicitly tell it to refresh. The database queries for stats() don't run on every keystroke. That's the key performance win , you're isolating the expensive parts of your component rather than paying for them on every interaction.

Rendering the Component

Include it in any Blade template the same way as any other Livewire component:

<livewire:search-posts />
Enter fullscreen mode Exit fullscreen mode

The component name is derived from the filename. The prefix and directory structure are stripped automatically. So ⚡search-posts.blade.php becomes search-posts. You can switch between single-file and multi-file formats without changing this reference.

For full-page components (search results as a standalone page, for example), use the pages:: namespace:

php artisan make:livewire pages::search
Enter fullscreen mode Exit fullscreen mode

And register the route with Route::livewire():

Route::livewire('/search', 'pages::search');
Enter fullscreen mode Exit fullscreen mode

When to Use Single-File vs Multi-File

Single-file is the right default for most components. It works well up to a few hundred lines, covers the vast majority of real-world use cases, and keeps everything co-located.

Multi-file makes sense when a component gets complex enough that one file becomes hard to navigate, or when you want a dedicated test file alongside the component. Create one with:

php artisan make:livewire post.editor --mfc
Enter fullscreen mode Exit fullscreen mode

This generates a directory:

resources/views/components/post/⚡editor/
├── editor.php
├── editor.blade.php
├── editor.js
└── editor.css
Enter fullscreen mode Exit fullscreen mode

The directory structure doesn't change how you reference the component. <livewire:post.editor /> still works. And you can convert between formats at any time:

# Single-file → Multi-file
php artisan livewire:convert search-posts

# Multi-file → Single-file
php artisan livewire:convert post.editor --single
Enter fullscreen mode Exit fullscreen mode

If a multi-file component has a test file, Livewire will warn you before converting to single-file since test files can't be preserved in that format.

Migrating a v3 Component

If you have existing v3 class-based components, nothing breaks. They keep working. But if you want to move a specific component to the new format, here's the before and after.

Before (v3 class-based):

app/Livewire/SearchPosts.php:

<?php

namespace App\Livewire;

use Livewire\Component;
use App\Models\Post;

class SearchPosts extends Component
{
    public string $query = '';

    public function render()
    {
        return view('livewire.search-posts', [
            'results' => Post::where('title', 'like', "%{$this->query}%")
                ->limit(10)
                ->get(),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

resources/views/livewire/search-posts.blade.php:

<div>
    <input wire:model.live="query" type="search" />

    @foreach($results as $post)
        <li>{{ $post->title }}</li>
    @endforeach
</div>
Enter fullscreen mode Exit fullscreen mode

After (v4 single-file):

resources/views/components/⚡search-posts.blade.php:

<?php

use Livewire\Component;
use Livewire\Attributes\Computed;
use App\Models\Post;

new class extends Component {

    public string $query = '';

    #[Computed]
    public function results()
    {
        return Post::where('title', 'like', "%{$this->query}%")
            ->limit(10)
            ->get();
    }

};

?>

<div>
    <input wire:model.live="query" type="search" />

    @foreach($this->results as $post)
        <li>{{ $post->title }}</li>
    @endforeach
</div>
Enter fullscreen mode Exit fullscreen mode

The main differences: no separate class file, no render() method, results accessed via $this->results with the #[Computed] attribute instead of being passed as view data, and the anonymous class definition instead of a named class in the App\Livewire namespace.

One v4 change worth knowing if you're migrating: wire:model no longer listens to events that bubble up from child elements. In v3, wire:model on a container element would catch input events from nested inputs inside it. That's gone in v4 , wire:model only responds to events directly on the element it's attached to. If you need the old behavior, add the .deep modifier: wire:model.deep. This catches most developers off guard the first time they hit it.

FAQ

Do I have to rewrite my existing v3 components?

No. Class-based components still work exactly as before. Single-file is the new default for components you create going forward, but nothing forces you to migrate old ones.

Can I use Filament with Livewire 4 single-file components?

Yes. Filament v5 runs on Livewire v4. Your Filament resources and custom pages are separate from your own Livewire components , they coexist without conflict. If you're building a Filament admin panel, you don't need to change anything about how Filament works.

Is the #[Computed] attribute required for properties accessed in the template?

No. You can still pass data to the template through a render() method if you want. #[Computed] is a convenience attribute that caches the result for the lifetime of the request and makes the property accessible as $this->results directly in the template. It's the cleaner pattern for v4.

Does wire:model.live work the same as in v3?

The debouncing behavior is the same, but the event bubbling behavior changed. In v4, wire:model only listens to events that originate directly on the element , not events that bubble up from children. For forms with standard inputs (text, select, textarea), you'll notice no difference. The change only affects non-standard uses like wire:model on a container element.

Can I still use Alpine.js inside single-file components?

Yes, fully. Alpine directives work in the template exactly as before. The <script> block gives you access to $wire for crossing the PHP-JavaScript boundary when you need it. Alpine handles client-side state, $wire handles server state.


The single-file format doesn't unlock anything that was impossible in v3 , it just removes the overhead of managing two files for every component. For small to medium components that's a genuine improvement, and for anything with scoped CSS or component-specific JavaScript it's significantly cleaner. If you're building a SaaS with Livewire and Filament, starting new components in the v4 format now means less context-switching and fewer files to track as the app grows.

Check the official Livewire v4 docs for the full component reference, including namespaces, slots, and attribute forwarding.

If you're planning a build and unsure whether Livewire or Inertia is the right call for your specific project, the Livewire 4 vs Inertia.js 3 comparison covers the decision in detail.

If you're building something with Livewire and want another dev to look it over, reach out.

Top comments (0)