Skip to content

Livewire Modals

The Admin package uses Livewire components as full-page modals, providing a seamless admin interface without traditional page reloads.

Overview

Livewire modals in Core PHP:

  • Render as full-page routes
  • Support direct URL access
  • Maintain browser history
  • Work with back/forward buttons
  • No JavaScript modal libraries needed

Creating a Modal

Basic Modal

php
<?php

namespace Mod\Blog\View\Modal\Admin;

use Livewire\Component;
use Mod\Blog\Models\Post;

class PostEditor extends Component
{
    public ?Post $post = null;
    public string $title = '';
    public string $content = '';
    public string $status = 'draft';

    protected array $rules = [
        'title' => 'required|max:255',
        'content' => 'required',
        'status' => 'required|in:draft,published',
    ];

    public function mount(?Post $post = null): void
    {
        $this->post = $post;

        if ($post) {
            $this->title = $post->title;
            $this->content = $post->content;
            $this->status = $post->status;
        }
    }

    public function save(): void
    {
        $validated = $this->validate();

        if ($this->post) {
            $this->post->update($validated);
            $message = 'Post updated successfully';
        } else {
            Post::create($validated);
            $message = 'Post created successfully';
        }

        session()->flash('success', $message);
        $this->redirect(route('admin.blog.posts'));
    }

    public function render()
    {
        return view('blog::admin.post-editor')
            ->layout('admin::layouts.modal');
    }
}
blade
{{-- resources/views/admin/post-editor.blade.php --}}
<x-hlcrf::layout>
    <x-hlcrf::header>
        <div class="flex items-center justify-between">
            <h1>{{ $post ? 'Edit Post' : 'Create Post' }}</h1>

            <button wire:click="$redirect('{{ route('admin.blog.posts') }}')" class="btn-ghost">
                <x-icon name="x" />
            </button>
        </div>
    </x-hlcrf::header>

    <x-hlcrf::content>
        <form wire:submit="save" class="space-y-6">
            <x-admin::form-group label="Title" name="title" required>
                <x-admin::input
                    name="title"
                    wire:model="title"
                    placeholder="Enter post title"
                />
            </x-admin::form-group>

            <x-admin::form-group label="Content" name="content" required>
                <x-admin::textarea
                    name="content"
                    wire:model.defer="content"
                    rows="15"
                />
            </x-admin::form-group>

            <x-admin::form-group label="Status" name="status" required>
                <x-admin::select
                    name="status"
                    :options="['draft' => 'Draft', 'published' => 'Published']"
                    wire:model="status"
                />
            </x-admin::form-group>

            <div class="flex gap-3">
                <x-admin::button type="submit" :loading="$isSaving">
                    {{ $post ? 'Update' : 'Create' }} Post
                </x-admin::button>

                <x-admin::button
                    variant="secondary"
                    wire:click="$redirect('{{ route('admin.blog.posts') }}')"
                >
                    Cancel
                </x-admin::button>
            </div>
        </form>
    </x-hlcrf::content>

    <x-hlcrf::right>
        <x-admin::help-panel>
            <h3>Publishing Tips</h3>
            <ul>
                <li>Write a clear, descriptive title</li>
                <li>Use proper formatting in content</li>
                <li>Save as draft to preview first</li>
            </ul>
        </x-admin::help-panel>
    </x-hlcrf::right>
</x-hlcrf::layout>

Registering Modal Routes

php
// Routes/admin.php
use Mod\Blog\View\Modal\Admin\PostEditor;
use Mod\Blog\View\Modal\Admin\PostsList;

Route::middleware(['web', 'auth', 'admin'])->prefix('admin/blog')->group(function () {
    Route::get('/posts', PostsList::class)->name('admin.blog.posts');
    Route::get('/posts/create', PostEditor::class)->name('admin.blog.posts.create');
    Route::get('/posts/{post}/edit', PostEditor::class)->name('admin.blog.posts.edit');
});

Opening Modals

blade
<a href="{{ route('admin.blog.posts.create') }}" class="btn-primary">
    New Post
</a>

Via Livewire Navigate

blade
<button wire:navigate href="{{ route('admin.blog.posts.create') }}" class="btn-primary">
    New Post
</button>

Via JavaScript

blade
<button @click="window.location.href = '{{ route('admin.blog.posts.create') }}'">
    New Post
</button>

With HLCRF

blade
<x-hlcrf::layout>
    <x-hlcrf::header>
        Modal Header
    </x-hlcrf::header>

    <x-hlcrf::content>
        Modal Content
    </x-hlcrf::content>

    <x-hlcrf::footer>
        Modal Footer
    </x-hlcrf::footer>
</x-hlcrf::layout>

Full-Width Modal

blade
<x-hlcrf::layout variant="full-width">
    <x-hlcrf::content>
        Full-width content
    </x-hlcrf::content>
</x-hlcrf::layout>

With Sidebar

blade
<x-hlcrf::layout variant="two-column">
    <x-hlcrf::content>
        Main content
    </x-hlcrf::content>

    <x-hlcrf::right width="300px">
        Sidebar
    </x-hlcrf::right>
</x-hlcrf::layout>

Advanced Patterns

php
public bool $showDeleteConfirmation = false;

public function confirmDelete(): void
{
    $this->showDeleteConfirmation = true;
}

public function delete(): void
{
    $this->post->delete();

    session()->flash('success', 'Post deleted');
    $this->redirect(route('admin.blog.posts'));
}

public function cancelDelete(): void
{
    $this->showDeleteConfirmation = false;
}
blade
@if($showDeleteConfirmation)
    <div class="fixed inset-0 bg-black/50 flex items-center justify-center">
        <div class="bg-white p-6 rounded-lg max-w-md">
            <h3 class="text-lg font-semibold mb-4">Delete Post?</h3>
            <p class="mb-6">This action cannot be undone.</p>

            <div class="flex gap-3">
                <x-admin::button variant="danger" wire:click="delete">
                    Delete
                </x-admin::button>
                <x-admin::button variant="secondary" wire:click="cancelDelete">
                    Cancel
                </x-admin::button>
            </div>
        </div>
    </div>
@endif
php
public int $step = 1;

public function nextStep(): void
{
    $this->validateOnly('step' . $this->step);
    $this->step++;
}

public function previousStep(): void
{
    $this->step--;
}
blade
<div>
    @if($step === 1)
        {{-- Step 1: Basic Info --}}
        <x-admin::input name="title" wire:model="title" label="Title" />
        <x-admin::button wire:click="nextStep">Next</x-admin::button>
    @elseif($step === 2)
        {{-- Step 2: Content --}}
        <x-admin::textarea name="content" wire:model="content" label="Content" />
        <x-admin::button wire:click="previousStep">Back</x-admin::button>
        <x-admin::button wire:click="nextStep">Next</x-admin::button>
    @else
        {{-- Step 3: Review --}}
        <div>Review and save...</div>
        <x-admin::button wire:click="previousStep">Back</x-admin::button>
        <x-admin::button wire:click="save">Save</x-admin::button>
    @endif
</div>
php
public string $search = '';
public array $results = [];

public function updatedSearch(): void
{
    $this->results = Post::where('title', 'like', "%{$this->search}%")
        ->limit(10)
        ->get()
        ->toArray();
}
blade
<x-admin::input
    name="search"
    wire:model.live.debounce.300ms="search"
    placeholder="Search posts..."
/>

<div class="mt-4">
    @foreach($results as $result)
        <div class="p-3 hover:bg-gray-50 cursor-pointer" wire:click="selectPost({{ $result['id'] }})">
            {{ $result['title'] }}
        </div>
    @endforeach
</div>

File Uploads

Single File

php
use Livewire\WithFileUploads;

class PostEditor extends Component
{
    use WithFileUploads;

    public $image;

    public function save(): void
    {
        $this->validate([
            'image' => 'required|image|max:2048',
        ]);

        $path = $this->image->store('posts', 'public');

        Post::create([
            'image_path' => $path,
        ]);
    }
}
blade
<x-admin::form-group label="Featured Image" name="image">
    <input type="file" wire:model="image" accept="image/*">

    @if($image)
        <img src="{{ $image->temporaryUrl() }}" class="mt-2 max-w-xs">
    @endif
</x-admin::form-group>

Multiple Files

php
public array $images = [];

public function save(): void
{
    $this->validate([
        'images.*' => 'image|max:2048',
    ]);

    foreach ($this->images as $image) {
        $path = $image->store('posts', 'public');
        // Save path...
    }
}

Real-Time Validation

php
protected array $rules = [
    'title' => 'required|max:255',
    'slug' => 'required|unique:posts,slug',
];

public function updated($propertyName): void
{
    $this->validateOnly($propertyName);
}
blade
<x-admin::input
    name="slug"
    wire:model.live="slug"
    label="Slug"
/>

@error('slug')
    <p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror

Loading States

blade
{{-- Show loading on specific action --}}
<x-admin::button wire:click="save" wire:loading.attr="disabled">
    <span wire:loading.remove wire:target="save">Save</span>
    <span wire:loading wire:target="save">Saving...</span>
</x-admin::button>

{{-- Disable form during loading --}}
<form wire:submit="save">
    <div wire:loading.class="opacity-50 pointer-events-none">
        {{-- Form fields --}}
    </div>
</form>

{{-- Spinner --}}
<div wire:loading wire:target="save" class="spinner"></div>

Events

Dispatch Events

php
// From modal
public function save(): void
{
    // Save logic...

    $this->dispatch('post-saved', postId: $post->id);
}

Listen to Events

php
// In another component
protected $listeners = ['post-saved' => 'refreshPosts'];

public function refreshPosts(int $postId): void
{
    $this->posts = Post::all();
}
blade
{{-- In Blade --}}
<div
    x-data
    @post-saved.window="alert('Post saved!')"
>
</div>

Best Practices

1. Use Route Model Binding

php
// ✅ Good - automatic model resolution
Route::get('/posts/{post}/edit', PostEditor::class);

public function mount(?Post $post = null): void
{
    $this->post = $post;
}

2. Flash Messages

php
// ✅ Good - inform user of success
public function save(): void
{
    // Save logic...

    session()->flash('success', 'Post saved');
    $this->redirect(route('admin.blog.posts'));
}

3. Validate Early

php
// ✅ Good - real-time validation
public function updated($propertyName): void
{
    $this->validateOnly($propertyName);
}

4. Use Loading States

blade
{{-- ✅ Good - show loading feedback --}}
<x-admin::button :loading="$isSaving">
    Save
</x-admin::button>

Testing

php
<?php

namespace Tests\Feature\Admin;

use Tests\TestCase;
use Livewire\Livewire;
use Mod\Blog\View\Modal\Admin\PostEditor;

class PostEditorTest extends TestCase
{
    public function test_creates_post(): void
    {
        Livewire::test(PostEditor::class)
            ->set('title', 'Test Post')
            ->set('content', 'Test content')
            ->set('status', 'published')
            ->call('save')
            ->assertRedirect(route('admin.blog.posts'));

        $this->assertDatabaseHas('posts', [
            'title' => 'Test Post',
        ]);
    }

    public function test_validates_required_fields(): void
    {
        Livewire::test(PostEditor::class)
            ->set('title', '')
            ->call('save')
            ->assertHasErrors(['title' => 'required']);
    }

    public function test_updates_existing_post(): void
    {
        $post = Post::factory()->create();

        Livewire::test(PostEditor::class, ['post' => $post])
            ->set('title', 'Updated Title')
            ->call('save')
            ->assertRedirect();

        $this->assertEquals('Updated Title', $post->fresh()->title);
    }
}

Learn More

Released under the EUPL-1.2 License.