Livewire 4 Single-File Components: Build a Live Search in One File
Livewire 4 ships a new single-file component format by default. Here's how to use it: from your first ⚡file to a complete live search with scoped CSS and Islands.
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
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();
}
};
?>
<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
</div>
<style>
.search-input {
width: 100%;
padding: 0.5rem 1rem;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
}
.results-list {
margin-top: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
list-style: none;
padding: 0;
}
.results-list li a {
display: block;
padding: 0.5rem 1rem;
color: #111827;
text-decoration: none;
}
.results-list li a:hover {
background-color: #f9fafb;
}
</style>
A few things to notice here. The <?php ?> block at the top is a proper PHP class definition, not a Blade-specific syntax. The anonymous class extends Livewire\Component the same way any Livewire component does. Everything below the closing ?> tag is your Blade template. And the <style> block at the bottom is automatically scoped to this component , those class names won't leak to other elements on the page.
The ⚡ in the filename is just a Unicode character. It works on Windows, macOS, Linux, and Git. If it bothers you, disable it in config/livewire.php:
'make_command' => [
'emoji' => false,
],
Scoped CSS
The CSS in a single-file component is scoped by default. Livewire uses CSS nesting under the hood. It wraps your selectors with a [wire:name] attribute selector that targets only your component's root element. Your .results-list class won't affect a .results-list element sitting in a different component on the same page.
This works in all modern browsers (Chrome 120+, Firefox 117+, Safari 17.2+). If you need to target older browsers, stick to Tailwind classes in the template instead.
If you actually need a style to apply globally (for example, you're loading a third-party library that adds classes outside your component), add the global attribute:
<style global>
.pikaday-calendar {
z-index: 9999;
}
</style>
Component JavaScript
You can add a <script> tag directly in the template. Inside it, you have automatic access to this , an alias for $wire, Livewire's JavaScript proxy for the component:
<script>
document.addEventListener('livewire:initialized', () => {
// Focus the search input when the component loads
this.$el.querySelector('.search-input').focus();
});
</script>
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>
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 />
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
And register the route with Route::livewire():
Route::livewire('/search', 'pages::search');
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
This generates a directory:
resources/views/components/post/⚡editor/
├── editor.php
├── editor.blade.php
├── editor.js
└── editor.css
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
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(),
]);
}
}
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>
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>
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.
About Hafiz
Senior Full Stack Developer. I build production software with Laravel, Filament, Vue, and AI integrations, and write about the real decisions behind shipping it.
Get in touch →Get web development tips via email
Join 50+ developers • No spam • Unsubscribe anytime
Related Articles
Livewire 4 vs Inertia.js 3: Which Laravel Frontend Stack Should You Use in 2026?
Livewire 4 and Inertia.js 3 both shipped major versions in 2026. Here's the deci...
Every Livewire Public Property Is a Form Field: The Security Audit Every Laravel App Needs
Your Livewire public properties are visible in the browser and modifiable by any...
PHP 8.4 Features You're Probably Not Using Yet in Your Laravel App
You upgraded to PHP 8.4 for Laravel 13. But are you actually using what it gives...