Livewire 4 Single-File Components: When to Put PHP and Blade in One File (and When Not To)

Livewire 4 single file components put PHP and Blade in one file. What actually changed, how computed properties replace view data, and when a directory wins.

Steven Richardson
Steven Richardson
· 10 min read

You upgrade to Livewire 4, run php artisan make:livewire search-users out of habit, and the file is not in app/Livewire. It is in resources/views/components, it has a lightning bolt in the filename, it has no class name and no render() method. The existing test that called Livewire::test(SearchUsers::class) now has nothing to point at.

Livewire 4 single file components are the new default. Nothing about your existing class-based components broke — but the shape of every new component changed, and a few habits have to change with it.

Generate a Livewire 4 single-file component and see where it landed#

Run the same make:livewire command you have always run. The output path is what moved.

php artisan make:livewire post.create

That writes a single file to resources/views/components/post/⚡create.blade.php:

<?php

use Livewire\Component;

new class extends Component {
    public $title = '';

    public function save()
    {
        // Save logic here...
    }
};
?>

<div>
    <input wire:model="title" type="text">
    <button wire:click="save">Save Post</button>
</div>

The ⚡ prefix is a Unicode character in the filename, not a magic token — it exists so component files stand out from ordinary Blade views in your editor sidebar and in search results. Git, grep, rsync and your deploy scripts do not care. If it bothers you, or a file watcher in your toolchain chokes on it, turn it off in config/livewire.php:

'make_command' => [
    'emoji' => false,
],

The same config block is the escape hatch for the whole change. Setting 'type' => 'class' restores Livewire 3 behaviour for every component you generate from then on.

Read the anatomy of a view-based component#

Everything familiar is still there — it just lives inside an anonymous class expression instead of a named one. Public properties, actions, lifecycle hooks, validation attributes and mount() all work exactly as they did.

<?php // resources/views/components/⚡search-users.blade.php

use App\Models\User;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;

new class extends Component {
    #[Url(as: 'q', except: '')]
    public string $search = '';

    #[Computed]
    public function results()
    {
        // Short-circuit: never run an unbounded query on an empty search.
        if (strlen($this->search) < 2) {
            return collect();
        }

        return User::query()
            ->where('name', 'like', "%{$this->search}%")
            ->limit(10)
            ->get();
    }
};
?>

<div>
    <input type="search" wire:model.live.debounce.300ms="search" placeholder="Search users">

    <ul>
        @foreach ($this->results as $user)
            <li wire:key="user-{{ $user->id }}">{{ $user->name }}</li>
        @endforeach
    </ul>
</div>

The one genuinely new constraint: no class name means no namespace, so nothing anywhere in your application can type-hint it, reference it by ::class, or jump to it from a type hint. That is the entire trade. Everything else is relocation. If you want the debounce behaviour tuned properly rather than copied, I went through the options in building a debounced live search input in Livewire.

Replace view data with computed properties#

A generated single-file component has no render() method, so there is no ->with([...]) and nowhere to pass variables. The replacement is #[Computed], accessed in the template through $this:

#[Computed]
public function posts()
{
    return Post::with('author')->latest()->get();
}
@foreach ($this->posts as $post)
    <article wire:key="post-{{ $post->id }}">{{ $post->title }}</article>
@endforeach

Computed properties memoise for the duration of a single request only. That is the source of the most common bug when people convert a component: an action mutates state, then reads the computed value in the same request, and gets the pre-mutation result back.

public function createPost()
{
    // $this->posts is memoised here, before the insert...
    if ($this->posts->count() > 10) {
        throw new \Exception('Maximum post count exceeded');
    }

    Auth::user()->posts()->create(...);

    // ...so the memo has to be busted or the view renders stale data.
    unset($this->posts);
}

If you want caching that survives between requests, #[Computed(persist: true)] wraps Laravel's cache for you, defaulting to 3600 seconds. unset() clears both the memo and the persisted cache entry. I covered the persist and cache: true variants, plus when each one is a trap, in caching heavy queries with persisted computed properties.

One Laravel 13 wrinkle worth knowing: new applications only unserialize explicitly allowed classes from the cache. If a persisted computed property returns an Eloquent model, Livewire silently re-evaluates it and logs a warning in debug mode rather than serving a cached value. Cache scalars and arrays, or add the classes to cache.serializable_classes in config/cache.php.

And render() is not gone — you can still declare one and pass data with $this->view([...]) when you genuinely need fresh values on every update. It is just no longer the default shape.

Render, route and reference a component with no class name#

Every place that used a class reference now uses the kebab-case string name. The name is derived from the file path, with the ⚡ prefix and file structure stripped, and it is identical across all three formats:

Format File path Component name
Single-file resources/views/components/post/⚡create.blade.php post.create
Multi-file resources/views/components/post/⚡create/create.php post.create
Class-based app/Livewire/Post/Create.php post.create

So a Blade tag stays the same regardless of format:

<livewire:post.create />
<livewire:pages::post.create />

Full-page components route through Route::livewire() with the string name:

Route::livewire('/posts/create', 'pages::post.create');
Route::livewire('/posts/{post}', 'pages::post.show'); // route model binding still works

Livewire ships two namespaces out of the box — pages:: pointing at resources/views/pages/ and layouts:: at resources/views/layouts/. Add your own in config/livewire.php under component_namespaces.

This is the part that bites during an upgrade: a stale App\Livewire\... reference left in routes/web.php after a conversion fails at request time with a class-not-found, not at boot. Grep for App\Livewire before you deploy.

Test a component by its string name#

Livewire::test() takes the same string:

<?php // resources/views/components/⚡search-users.test.php

use App\Models\User;
use Livewire\Livewire;

it('filters users by name', function () {
    User::factory()->create(['name' => 'Ada Lovelace']);
    User::factory()->create(['name' => 'Grace Hopper']);

    Livewire::test('search-users')
        ->set('search', 'Ada')
        ->assertSee('Ada Lovelace')
        ->assertDontSee('Grace Hopper');
});

Generate the test alongside the component with --test. For a single-file component it lands next to the file as ⚡search-users.test.php; with --mfc it goes inside the component directory.

Tests living under resources/views need two bits of wiring. In tests/Pest.php:

pest()->extend(Tests\TestCase::class)
    ->in('Feature', '../resources/views');

And a test suite in phpunit.xml:

<testsuite name="Components">
    <directory suffix=".test.php">resources/views</directory>
</testsuite>

The gotcha: a typo in the component name produces "Component [serch-users] not found" rather than a helpful class-not-found. You lose the compiler's spell-check, so a shared constant for the name is worth the two lines in anything you test heavily. My broader approach to testing Livewire components with Pest applies unchanged here — only the first argument differs.

Split a Livewire 4 single-file component into a multi-file directory#

Multi-file components are not a compatibility fallback. They are the right shape once a component grows, and the --mfc flag generates the directory:

php artisan make:livewire post.create --mfc --js --css --test
resources/views/components/post/⚡create/
├── create.php          # PHP class
├── create.blade.php    # Blade template
├── create.js           # JavaScript
├── create.css          # Scoped styles
├── create.global.css   # Global styles
└── create.test.php     # Pest test

My rule for reaching for --mfc:

  • The component needs its own JS or CSS. The directory holds them and Livewire compiles them, with styles scoped to the component.
  • It is past roughly 150 lines. Scrolling past a screenful of PHP to reach the markup is worse than two files.
  • It has a colocated test you actually maintain. Single-file components can have a sibling test, but the directory keeps it genuinely together.
  • It is a shared or package component. Discoverability beats colocation the moment other people consume it.

Single-file wins for small leaf components, one-off page fragments, and the very common "this exists only to make one table filterable" case. Both formats support islands and lazy loading identically, so performance is never the deciding factor.

Convert one existing component without sweeping the codebase#

Livewire ships a converter that works in both directions:

php artisan livewire:convert post.create --mfc   # single-file → directory
php artisan livewire:convert post.create --sfc   # directory → single-file

Run it without a flag and it auto-detects the current format and flips it. One warning: converting a multi-file component to single-file deletes the test file, because the format cannot hold one. Livewire prompts first, but it is easy to confirm on autopilot.

Do not run this across your whole application. Class-based components are fully supported and there is no functional benefit to converting them — you get an unreviewable diff and a weekend of churn. Convert a component when you are already editing it for another reason, and never as a sweep.

Debug a compiled component when the stack trace lies#

This is the section that saves you an afternoon. Livewire compiles single-file components into anonymous classes with hashed filenames in the cache directory, so a production exception gives you a trace like:

/storage/framework/cache/livewire/ab1234cd.php:42

That path names no component, and line 42 in the compiled output does not correspond to line 42 in your source. Livewire keeps a resolved-component map that ties the component name to the compiled path, which is how error trackers reverse it — Flare's write-up on mapping compiled single-file components is the clearest description of the mechanism I have found, and Flare, Sentry and Bugsnag all handle it now. Check your reporter actually does before you rely on it.

For local debugging the fast path is php artisan view:clear followed by reproducing the error with APP_DEBUG=true, which renders the source frame properly. Static analysis is the other cost: PHPStan and Larastan see a lot less inside an anonymous class expression embedded in a Blade file than they do in a named class, so heavily-analysed domain components are another argument for --class or --mfc.

Gotchas and Edge Cases#

Duplicate names collide. Two single-file components with the same filename in different directories throw duplicate-class errors at compile time. Rename one or namespace the directory.

wire:key is easier to forget. In a single file the markup sits further from the state that drives it, and a missing wire:key in a loop produces the classic morph bug where the wrong row updates.

Protected properties need $this->. Public properties are available as {{ $title }}; protected ones are {{ $this->apiKey }} and are never sent to the client — which also means they do not persist between requests.

Computed properties do not work on Form objects. If you are using Livewire\Form classes, accessing a computed through $form->property errors. Keep the computed on the component. Form objects with validation attributes still pair fine with single-file components, just not that way.

A blank render usually means no root element. Livewire still requires exactly one root element in the template half of the file, and a PHP syntax error in the top half often surfaces as a blank component rather than an exception.

Wrapping Up#

Default to single-file for new components, reach for --mfc the moment a component needs its own JS, CSS or a colocated test you maintain, and leave your class-based components alone. The formats are interchangeable by component name, so this is a decision you can revisit per component instead of per project.

If you are still mid-upgrade, the sequencing matters more than the format choice — I wrote up the order I run it in migrating from Livewire 3 to Livewire 4. And if the component you are converting holds anything the client should not be able to change, check it against locked properties while you have the file open.

FAQ#

Where does make:livewire put components in Livewire 4?

php artisan make:livewire post.create writes a single file to resources/views/components/post/⚡create.blade.php. Components created with the pages:: namespace go to resources/views/pages/ instead. You can restore the Livewire 3 location by setting make_command.type to class in config/livewire.php, which puts the class back in app/Livewire with a separate view.

What is the difference between single-file and multi-file Livewire components?

A single-file component holds the anonymous PHP class and the Blade markup in one .blade.php file. A multi-file component, created with --mfc, is a directory containing separate .php, .blade.php, .js, .css and .test.php files. Both compile to the same thing and are referenced by the same component name, so switching between them requires no changes to your Blade tags or routes.

How do I pass data to the view without a render method in Livewire 4?

Use computed properties. Add the #[Computed] attribute to a method and access it in the template as $this->methodName. Values are memoised for the current request only, so call unset($this->methodName) after any action that changes the underlying data. If you genuinely need a render() method you can still declare one and pass data through $this->view([...]).

How do I test a Livewire component that has no class name?

Pass the kebab-case component name as a string: Livewire::test('search-users'). Every other testing method works identically to a class-based component. If your test file lives next to the component under resources/views, add that directory to tests/Pest.php and register a .test.php test suite in phpunit.xml so Pest picks it up.

Are class-based Livewire components deprecated in v4?

No. Class-based components are fully supported and php artisan make:livewire Name --class still generates them. The change in Livewire 4 is which format is the default, not which formats are allowed. Converting an existing application wholesale gains you nothing functionally, so convert components individually when you are already working in them.

Why is my Livewire stack trace pointing at a compiled file?

Livewire compiles single-file components into anonymous classes stored under storage/framework/cache/livewire/ with hashed filenames, and the raw trace reports that compiled path and its line number. Error trackers that support Livewire 4 reverse the hash back to your source file and remap the line number. Locally, php artisan view:clear and reproducing with APP_DEBUG=true gives you the correct source frame.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.