Livewire 4 is finally here, and it's the biggest release yet.
This isn't about adding complexity—it's about better defaults, less friction, and more powerful tools for building exactly what you want. We've been heads-down for months rethinking how Livewire components should feel, and we're proud of where we landed.
Let's take a look.
View-based components
The most visible change in Livewire 4 is how you write components. Instead of bouncing between a PHP class and a Blade file, you can now put everything in a single file:
<?php // resources/views/components/⚡counter.blade.php use Livewire\Component; new class extends Component { public $count = 0; public function increment() { $this->count++; }};?> <div> <h1>{{ $count }}</h1> <button wire:click="increment">+</button></div> <style> /* Scoped CSS... */</style> <script> /* Component JavaScript... */</script>
This is now the default when you run php artisan make:livewire. The lightning bolt emoji makes Livewire components instantly recognizable in your file tree—you can immediately tell a Livewire component from a Blade component at a glance. (You can disable it if emojis aren't your thing.)
For larger components, there's also a multi-file format that keeps everything together in a single directory:
⚡counter/├── counter.php├── counter.blade.php├── counter.css (optional)├── counter.js (optional)└── counter.test.php (optional)
Create one with --mfc, and convert between formats anytime with php artisan livewire:convert.
Routing
Reference components the same way everywhere. Livewire 4 introduces Route::livewire():
// Before (v3) - Still supportedRoute::get('/posts/create', CreatePost::class); // After (v4)Route::livewire('/posts/create', 'pages::post.create');
The new syntax references components by name rather than class. This matches how you render components everywhere else in your app.
Namespaces
Livewire now ships with opinions about app structure. By default, you get two namespaces: pages:: for page components and layouts:: for layouts—everything else goes in resources/views/components alongside your Blade components.
Route::livewire('/dashboard', 'pages::dashboard');
For modular applications, you can register your own namespaces. Group admin components under admin::, billing under billing::, or whatever makes sense for your architecture.
Scripts and styles
Component JavaScript and CSS now live alongside your components. Add <script> and <style> tags directly in your template:
<div> <h1 class="title">{{ $count }}</h1> <button wire:click="$js.celebrate">+</button></div> <style>.title { color: blue; font-size: 2rem;}</style> <script> this.$js.celebrate = () => { confetti() }</script>
Styles are automatically scoped to your component—your .title class won't leak to other parts of the page. Need global styles? Add the global attribute: <style global>.
Scripts have access to this for component context—an alias for $wire that you might be used to using.
Both are served to the browser as native .js/.css files that are automatically cached for optimal performance.
Islands
Islands are the headline feature of Livewire 4. They let you create isolated regions within a component that update independently:
<div> @island <div> Revenue: {{ $this->revenue }} <button wire:click="$refresh">Refresh</button> </div> @endisland <div> <!-- This won't re-render when the island updates --> Other content... </div></div>
When you click "Refresh," only the island re-renders. The rest stays untouched. Previously, you'd need to extract this into a separate child component with all the overhead of props and events to get a similar level of isolation.
The performance benefits go deeper than just DOM updates. When you combine islands with computed properties, only the data needed by that island gets fetched. If your component has three islands each referencing different computed properties, refreshing one island only runs that island's queries. You're isolating overhead from the database all the way to the rendered HTML.
Islands support lazy loading (lazy: true), naming for cross-component targeting (name: 'revenue'), and appending content for infinite scroll:
<button wire:click="loadMore" wire:island.append="feed"> Load more</button>
Slots and attribute forwarding
If you've used slots and attribute forwarding in Blade components, you'll feel right at home.
Slots let parents inject content into children while keeping everything reactive:
<livewire:card :$post> <h2>{{ $post->title }}</h2> <button wire:click="delete({{ $post->id }})">Delete</button></livewire:card>
The slot content is evaluated in the parent's context, so wire:click="delete" calls the parent's method.
Attribute forwarding lets you pass HTML attributes through:
<livewire:post.show :$post class="mt-4" /> <!-- Inside post.show component --><div {{ $attributes }}> ...</div>
Drag and drop
Drag-and-drop sorting, no external library required:
<ul wire:sort="reorder"> @foreach ($items as $item) <li wire:key="{{ $item->id }}" wire:sort:item="{{ $item->id }}"> {{ $item->title }} </li> @endforeach</ul>
public function reorder($item, $position){ // $item is the ID, $position is the new index}
It handles smooth animations automatically. Add drag handles with wire:sort:handle, prevent interactive elements from triggering drags with wire:sort:ignore, and drag between multiple lists using wire:sort:group.
Smooth transitions
The wire:transition directive adds hardware-accelerated animations using the browser's View Transitions API:
@if ($showAlertMessage) <div wire:transition> <!-- Message smoothly fades in/out --> </div>@endif
For step wizards or carousels where direction matters, you can specify transition types:
#[Transition(type: 'forward')]public function next() { $this->step++; } #[Transition(type: 'backward')]public function previous() { $this->step--; }
Then customize the CSS animations for each direction using ::view-transition-old() and ::view-transition-new() pseudo-elements.
Optimistic UI
Make your interfaces feel instant. These directives update the page immediately—no round-trip required.
wire:show toggles visibility using CSS (no DOM removal, no network request):
<div wire:show="showModal"> <!-- Hidden/shown instantly --></div>
wire:text updates text content immediately:
Likes: <span wire:text="likes"></span>
wire:bind binds any HTML attribute reactively:
<input wire:model="message" wire:bind:class="message.length > 240 && 'text-red-500'">
$dirty tracks unsaved changes:
<div wire:show="$dirty">You have unsaved changes</div><div wire:show="$dirty('title')">Title modified</div>
Loading states
In addition to the existing wire:loading from v3, Livewire 4 automatically adds a data-loading attribute to any element that triggers a network request.
This makes it simple to style loading states directly from CSS and target sibling, parent, or child elements:
<button wire:click="save" class="data-loading:opacity-50"> Save <svg class="not-in-data-loading:hidden">...</svg></button>
Inline placeholders
For lazy components and islands, the @placeholder directive lets you define loading states right next to the content they replace:
@placeholder <div class="animate-pulse h-32 bg-gray-200 rounded"></div>@endplaceholder <div> <!-- Actual content loads here --></div>
No separate placeholder views or methods—your skeleton lives within your component.
JavaScript power tools
When you need to drop into JavaScript, Livewire 4 meets you there.
wire:ref gives elements names you can target:
<livewire:modal wire:ref="modal" />
$this->dispatch('close')->to(ref: 'modal');
You can also access refs from component scripts:
<input wire:ref="search" type="text" /> <script> this.$refs.search.addEventListener('keydown', (e) => { // Handle keyboard events... })</script>
#[Json] methods return data directly to JavaScript:
#[Json]public function search($query){ return Post::where('title', 'like', "%{$query}%")->get();}
<script> let results = await this.search('livewire') console.log(results)</script>
$js actions run client-side only:
<button wire:click="$js.bookmark">Bookmark</button> <script> this.$js.bookmark = () => { this.bookmarked = !this.bookmarked this.save() }</script>
Interceptors hook into requests at every level:
<script> this.intercept('save', ({ onSuccess, onError }) => { onSuccess(() => showToast('Saved!')) onError(() => showToast('Failed to save', 'error')) })</script>
Use global interceptors for app-wide concerns like session expiration:
Livewire.interceptRequest(({ onError }) => { onError(({ response, preventDefault }) => { if (response.status === 419) { preventDefault() if (confirm('Session expired. Refresh?')) { window.location.reload() } } })})
Upgrading
Livewire 4 maintains strong backwards compatibility. Your existing components will continue to work—the new single-file format is the default for new components, but class-based components remain fully supported.
If you want to see all of this in action, I recorded a new Laracasts series covering every feature in depth with real-world examples. Watch the Livewire 4 series →
Livewire 4 is available now:
composer require livewire/livewire:^4.0
For the complete documentation, visit livewire.laravel.com.
Hi friends! I'm Caleb. I created Livewire and Alpine because I love building tools that make web development feel like magic. I also love to fly fish!