marcorombach/livewire-confirm-modal
A Livewire directive that shows a styled confirm modal instead of the browser's native confirm() dialog, so confirmations look consistent across browsers. Supports custom titles and descriptions via wire:confirm-modal, works with wire:click, and the view is publishable for your own Tailwind styling.
Install and wire up the three moving parts. The package has no service provider you need to touch, but it does have two prerequisites that are easy to skip and produce silent failures:
composer require marcorombach/livewire-confirm-modal
1. Tailwind content path — the shipped Blade view hardcodes Tailwind classes, so those classes must be in your build's scan set:
// tailwind.config.js
content: [
"./resources/**/*.blade.php",
"./resources/**/*.js",
"./vendor/marcorombach/livewire-confirm-modal/resources/views/components/*.php",
],
2. Mount the component once per layout — the modal element and its script live in this tag, and every view that fires the directive needs it in its layout tree:
{{-- resources/views/layouts/app.blade.php --}}
<body>
{{ $slot }}
<x-livewire-confirm-modal/>
</body>
Where to look first. After install, read the published view before you write any call sites — it is small, and it tells you exactly what Alpine state the directive feeds and what buttons exist. Publish it immediately so it lives in your repo rather than vendor/:
php artisan vendor:publish --tag="livewire-confirm-modal-views"
This is the single most important early decision. A published view is version-controlled, reviewable, and diffable on upgrade. An unpublished one is invisible until the maintainer changes it under you.
First use case. The canonical one is a destructive action in a table row:
<button
wire:confirm-modal="Delete user|This will permanently remove {{ $user->name }} and cannot be undone."
wire:click="delete({{ $user->id }})"
class="text-red-600 hover:underline"
>
Delete
</button>
The directive intercepts the click, renders your modal, and only releases wire:click when the user confirms. Nothing happens server-side until then — no request, no method call, no round trip.
The directive's title|description string DSL is the API your whole team will copy-paste. Do not expose it directly. Build a thin internal wrapper that owns escaping, localization, and a saner signature:
{{-- resources/views/components/confirm-button.blade.php --}}
@props([
'action', // e.g. "delete(1)"
'title' => 'Are you sure?',
'description' => 'This action cannot be undone.',
'variant' => 'danger',
])
@php
// The directive splits on "|", so the delimiter must not survive inside copy.
$payload = str_replace('|', '¦', $title . '|' . $description);
@endphp
<button
wire:confirm-modal="{{ $payload }}"
wire:click="{{ $action }}"
{{ $attributes->class([
'text-sm font-medium',
'text-red-600 hover:text-red-800' => $variant === 'danger',
'text-indigo-600 hover:text-indigo-800' => $variant === 'primary',
]) }}
>
{{ $slot }}
</button>
Call sites then read as intent, not as vendor syntax:
<x-confirm-button action="delete({{ $user->id }})" :title="__('users.delete.title')" :description="__('users.delete.body', ['name' => $user->name])">
{{ __('common.delete') }}
</x-confirm-button>
This is what makes the package swappable later without touching forty templates.
The package ships a few language strings, but your modal copy is product copy — treat it as translations in your own lang/ files, resolved at render time through the wrapper. The | delimiter is the trap here: any translated sentence containing a pipe character silently corrupts the payload, and translators will not know about your DSL. Escaping in the wrapper is the only durable fix.
wire:loading companionBecause the modal is non-blocking and asynchronous, the gap between "user confirmed" and "server responded" is visible. Pair the directive with a loading state on the same button so double-clicks during the request do not enqueue twice:
<button
wire:confirm-modal="Archive project|..."
wire:click="archive({{ $project->id }})"
wire:loading.attr="disabled"
wire:target="archive"
class="disabled:opacity-50"
>
Archive
<span wire:loading wire:target="archive">…</span>
</button>
The directive operates per-element, so it composes fine with @foreach. The one requirement is that the string be resolved at render time — pass model attributes as interpolated Blade, not as JS variables:
@foreach ($invoices as $invoice)
<x-confirm-button
action="void({{ $invoice->id }})"
:title="__('invoices.void.title')"
:description="__('invoices.void.body', ['number' => $invoice->number])"
>
Void #{{ $invoice->number }}
</x-confirm-button>
@endforeach
Each button gets its own modal invocation; you do not need one <x-livewire-confirm-modal/> per row.
The modal is a UI affordance, not an authorization boundary. Anyone can call the Livewire method directly. Keep your policy checks and $this->authorize() calls in the action exactly as you would without the modal:
public function delete(User $user)
{
$this->authorize('delete', $user);
$user->delete();
$this->dispatch('notify', message: 'User deleted.');
}
The modal lives entirely client-side, so Livewire feature tests bypass it completely — Livewire::test(...)->call('delete', $id) executes the action with no confirmation. That is fine and desirable: test the action's authorization and side effects, and cover the modal itself with a Dusk/browser test if the confirmation flow is business-critical. Do not try to assert the modal from a standard feature test; you will be asserting markup that never blocks PHP.
Missing <x-livewire-confirm-modal/> swallows the click with no error. This is the package's worst failure mode. The directive intercepts the event, looks for the modal, finds nothing, and returns — no request, no console warning, no network entry. The button just stops working in that one layout. Add "layout renders the modal tag" to your new-layout checklist, and grep your layouts before rollout:
grep -rn "livewire-confirm-modal" resources/views/layouts/
Tailwind purge will break this in production only. Local dev renders correctly even if the content path is missing, because Tailwind's JIT has already seen the class names during development. Production purge drops them and the modal renders as an unstyled white block over your content. Verify against a real npm run build, not the dev server.
| in copy is the silent corruption vector. The directive is title|description, split naively. A description containing a pipe — common in technical copy ("name | email | role") and in some translations — truncates or misaligns the payload. The wrapper above replaces it with ¦; do that once rather than remembering the rule per call site.
Wire:click only. The documented and tested surface is wire:click. wire:submit on forms, wire:poll actions, and plain HTML form posts are not covered. If you need confirmation on a form submit, either move the action to a wire:click on the submit button or handle confirmation in your own component. Verify any non-click trigger manually before you demo it.
Directive order matters at the call site. wire:confirm-modal must be able to intercept before wire:click dispatches. Keep them on the same element and put the confirm directive first in the attribute list, as in the README example. Splitting them across a parent and child element is not supported.
Async, not blocking. Native confirm() returns a boolean synchronously; this modal does not. Any handler that assumed it could branch on the return value, or that chained a second action immediately after, needs rewriting. Concretely: if you had if (confirm(...)) doThing(), that pattern does not translate.
Publishing the view is a fork, whether you call it one or not. Once the file is in your repo, upstream fixes stop reaching you. Record the decision, name an owner, and note the pinned version. When you upgrade Livewire or Tailwind, treat "check the confirm modal" as part of the same ticket — the changelog history is a run of Alpine and Tailwind compatibility fixes (0.2.5, 0.2.6), which tells you upstream majors have historically broken it.
Accessibility is not claimed by the README. No documented ARIA roles, focus trap, Escape-to-close, or focus restoration. Since this dialog gates destructive actions, that becomes your liability at ship time. Read the published view and verify: is focus moved into the modal on open? Is role="dialog" present? Does Escape close? If the answers matter for your surface, budget the hardening pass in the fork — it is a few Alpine lines, not a rewrite.
Alpine coexistence. The view embeds Alpine directives. If your page already has Alpine state or x-data scoped around the button, verify the modal still opens — 0.2.5 was specifically a fix for the directive failing when no Alpine directives were used, which hints at fragile coupling. Test on a page with real Alpine interactivity, not on a bare button.
Stale modal copy after re-render. The title|description string is baked into the DOM at render. If the underlying model changes via Livewire and the button is re-rendered, the modal picks up the new string — but if you interpolate a value inside a wire:ignore region, you will show stale copy. Keep the confirm payload out of ignored subtrees.
Debugging checklist when a confirmation does not fire. In order: (1) is <x-livewire-confirm-modal/> in this layout? (2) is the directive on the same element as wire:click? (3) does the payload contain a stray |? (4) does the console show an Alpine error — usually a version mismatch or a second Alpine instance from another package? (5) rebuild production assets. That sequence resolves nearly every report, and the first item resolves most of them.
How can I help you explore Laravel packages today?