steelants/modal
A lightweight Laravel package from SteelAnts for adding modal dialogs to your application. It provides a simple way to define and render modals in Blade views, keeping setup minimal and integration straightforward for common UI needs.
Minimal setup
composer require steelants/modal
bootstrap.bundle.min.js you get a rendered div that never opens (no backdrop, no ESC, no scroll lock).php artisan vendor:publish --provider="Steelants\Modal\ModalServiceProvider"
Check config/modal.php afterward for default size/backdrop/close behaviour.</body>:
<livewire:modal />
Where to look first
README.md for the exact event name and payload shape — this is the one thing to verify before writing code. The examples here assume the common openModal event that takes a component name plus a params array; if the package expects a FQCN instead, swap accordingly.src/ for the host component and any trait/helper it ships.resources/views/ — it's a thin Blade wrapper around Bootstrap's modal markup, which is usually faster to read than the docs.config/ for the knobs you'll actually touch (default size, backdrop, whether the child is kept alive across opens).First use case: a delete confirmation
From an index page:
// app/Http/Livewire/Users/Index.php
public function confirmDelete(int $userId): void
{
$this->dispatch('openModal', component: 'users.delete', params: ['userId' => $userId]);
}
<button wire:click="confirmDelete({{ $user->id }})" class="btn btn-sm btn-danger">
Delete
</button>
// app/Http/Livewire/Users/Delete.php
class Delete extends Component
{
public int $userId;
public function mount(int $userId): void
{
$this->userId = $userId;
}
public function delete(): void
{
User::findOrFail($this->userId)->delete();
$this->dispatch('closeModal');
$this->dispatch('user-deleted')->to(Index::class);
}
public function render()
{
return view('livewire.users.delete');
}
}
This is the highest-value pattern: replacing full page navigations for confirmations and small create/edit forms with a modal that reuses an existing Livewire component.
Passing data in, sending results back out
The modal child is a normal Livewire component, so mount() receives the params array. The return path matters more: closing the modal destroys the child, so the parent must be told to refresh.
// inside the modal child, after a successful save
$this->dispatch('closeModal');
$this->dispatch('user-saved')->to(Index::class);
// Index
#[On('user-saved')]
public function refreshList(): void
{
// no-op; a re-render is enough, or reset pagination here
}
Prefer scoped dispatches (->to()) over global ones. A global closeModal broadcast from a nested component will close every open modal, which is rarely what you want.
One form component, two entry points
Because the modal just mounts a component, you can share a form between a dedicated page and a modal:
class UserForm extends Component
{
public ?User $user = null;
public bool $inModal = false;
public function mount(?int $userId = null, bool $inModal = false): void
{
$this->user = $userId ? User::find($userId) : new User();
$this->inModal = $inModal;
}
public function save(): void
{
$this->validate();
$this->user->save();
if ($this->inModal) {
$this->dispatch('closeModal');
$this->dispatch('user-saved')->to(Index::class);
return;
}
$this->redirect(route('users.index'));
}
}
Keep the branching at the top level of save()/cancel(); don't scatter $this->inModal through the view. The Blade file should just render the form fields and pick its footer buttons.
Triggering from Blade/Alpine (non-Livewire buttons)
For plain buttons, dispatch the same browser event the host listens for:
<button
x-data
x-on:click="$dispatch('openModal', { component: 'users.form', params: { userId: {{ $user->id }}, inModal: true } })"
class="btn btn-primary">
Edit
</button>
This is the escape hatch when you don't want a round trip just to open a dialog. Watch the JSON-ish escaping — quotes inside x-on attributes are the usual source of broken modals.
Sizing and options
Pass size/backdrop through the params (or whatever the config/event supports) rather than editing package views:
$this->dispatch('openModal', component: 'users.form', params: [
'userId' => $id,
'inModal' => true,
], size: 'lg');
Confirm in src/ which keys are read; if the package doesn't support per-call sizing, publish the view once and override it there instead of editing vendor/.
Testing
The parent's behaviour is testable without any DOM:
Livewire::test(Index::class)
->call('confirmDelete', $user->id)
->assertDispatched('openModal');
Livewire::test(Delete::class, ['userId' => $user->id])
->call('delete')
->assertDispatched('closeModal');
$this->assertDatabaseMissing('users', ['id' => $user->id]);
Test the child component directly. There's no value in asserting Bootstrap markup — that's the package's job, and you're not going to catch regressions there anyway.
The Bootstrap JS dependency is the #1 failure mode. Symptoms: the modal markup appears in the DOM but is invisible, or opens with no backdrop and leaves the page scroll-locked. Confirm bootstrap.bundle.min.js is loaded exactly once, and that nothing else on the page (a second Bootstrap copy, a theme) re-registers Modal. Check window.bootstrap.Modal in the console first when a modal "doesn't work."
Livewire 2 vs 3 event API. The package targets whichever Livewire generation it was built against. In Livewire 3 use $this->dispatch(...); in Livewire 2 use $this->emit(...) for server-side and Livewire.emit(...) client-side — they don't cross over. Passing named arguments (component:, params:) vs a positional array also differs between versions; if opens silently do nothing, this is usually why.
State is not preserved across opens. Closing destroys the child, so mount() runs again on the next open. That's what you want for a create form (clean state) and a source of bugs for anything that assumed persistence. Don't stash draft state in a modal child expecting it to survive.
Validation errors won't show unless the view has the slots. $this->validate() works fine, but if your modal Blade file doesn't include @error('field') blocks (and wire:model is deferred), the user sees nothing happen. Use wire:model.blur or .live on fields you validate inline, and always render @error next to the input.
Z-index and stacking. Opening a second modal over the first, or a Select2/datepicker inside a modal, is a Bootstrap problem, not a Livewire one — dropdowns get clipped by overflow on .modal-body. Fix with data-bs-container="body" on the picker or a .modal-body { overflow: visible } override. Avoid nested modals entirely; render a step-based form inside one modal instead.
Escape/backdrop close loses unsaved work silently. If users will type into the modal, set backdrop: 'static' and keyboard: false for that instance, or run your own confirm-on-dirty check. Bootstrap will close the modal and Livewire will discard the child before you can intervene.
Accessibility is likely thin. Small packages at this star count rarely implement a focus trap beyond what Bootstrap already gives you. Verify focus returns to the trigger after close and that aria-labelledby points at a real heading; add it yourself in the published view if not.
Pin the version and keep a fork path open. Two stars, zero dependents, and MIT licensed means the realistic plan is: composer require steelants/modal:^1.0 with an exact-ish constraint, and if it stalls, vendor resources/views/ into your app and copy the ~few hundred lines of component code. The surface area is small enough that replacing it with your own <livewire:modal /> wrapper plus a #[On('openModal')] listener is an afternoon's work — which is also the honest argument against adopting it in the first place if the modal is on a critical path.
Debugging order when "nothing happens": (1) is <livewire:modal /> actually in the rendered layout — document.querySelector('[wire\\:id]') and check for the modal wrapper; (2) does the dispatched event name match what the host listens for — inspect the host component's #[On] attribute; (3) is Bootstrap JS present; (4) check the network tab for the Livewire open request — if it never fires, the event name or payload shape is wrong, not Bootstrap.
Extension point: the cleanest customisation is a published Blade view plus a config default (size, backdrop, close button). Reach for that before subclassing the host component — subclassing couples you to internals that will not be documented.
How can I help you explore Laravel packages today?