tuhin-su/livewire-swal
Reusable SweetAlert2 integration for Laravel 10/11+ and Livewire 3, with zero-config CDN injection and one-line PHP helpers for toasts, modals, confirmations, input and password prompts, plus server-verified and cryptographically signed secure actions.
1. Install
composer require tuhin-su/livewire-swal
That's the whole setup for the default path. The package ships a middleware that injects the SweetAlert2 CDN <script> tag and its JS event wrapper before </body> on every HTML response — no npm, no Vite, no layout edits.
2. Add the trait
namespace App\Livewire;
use Livewire\Component;
use LaravelSwal\Traits\Swal;
class Dashboard extends Component
{
use Swal;
public function save()
{
// ... persist
$this->swalToastSuccess('Saved', 'Record updated.');
}
}
3. Where to look first
README.md — helper signatures are all there; the "Secure Actions" section is the part worth reading carefully.config/laravel-swal.php after php artisan vendor:publish --tag=laravel-swal-config — the only knobs that matter are auto_inject and swal_cdn.resources/js/vendor/laravel-swal/swal.js (if you publish assets) — this is the actual event bridge between PHP dispatches and Swal.fire().First real use case: replace your session()->flash('message', ...) success banners with $this->swalToastSuccess(...) at the end of a form submit, and replace any confirm('Delete?') JS with $this->swalConfirm(...). That's a 10-minute win.
public function save()
{
$this->validate();
$this->post->save();
$this->swalToastSuccess('Saved', 'Post updated.', ['timer' => 3000]);
}
Everything accepts a third $opts array that is passed straight through to SweetAlert2's config object, so anything in the SWAL2 docs is available.
The pattern that matters for destructive UI: you fire a confirm from PHP, and the actual work happens in an #[On] listener that only runs if the user clicked "Confirm".
// Trigger
public function askDelete(int $id)
{
$this->swalConfirm(
title: 'Delete post?',
text: 'This cannot be undone.',
opts: ['confirmButtonText' => 'Delete'],
thenEvent: 'posts.delete',
thenParams: ['id' => $id],
);
}
// Handler (separate method, same component)
use Livewire\Attributes\On;
#[On('posts.delete')]
public function delete(array $payload)
{
Post::find($payload['id'])->delete();
$this->swalToastSuccess('Deleted', 'Post removed.');
}
Use $thenEventTo (a component name/ID) when the listener lives on a different Livewire component — handy for a global "delete row" button on an index page whose listener is on a parent component.
$this->swalTakeInput(
title: 'Add note',
input: 'textarea',
thenEvent: 'notes.save',
);
#[On('notes.save')]
public function storeNote(array $payload)
{
$this->note = trim($payload['value'] ?? '');
// ...
}
Same shape for swalPromptPassword — it's just swalTakeInput with input: 'password' preset.
For "confirm your password before X" flows, swalVerifyCurrentUserPassword is the shortest path — it runs Hash::check server-side, toasts the result automatically, and dispatches your $thenEventTrue or $thenEventFalse:
$this->swalVerifyCurrentUserPassword(
thenEventTrue: 'settings.unlocked',
thenEventFalse: 'settings.locked',
);
Regular swalConfirm flows are bypassable — a user can open the console and Livewire.dispatch('posts.delete', {id: 5}) directly. swalSecureAction closes that hole by encrypting the method name and params with APP_KEY, binding the payload to the session and component class, and calling a protected method server-side after the multi-step prompt succeeds.
public function initiateDelete(int $id)
{
$this->swalSecureAction(
method: 'deleteUser',
params: ['id' => $id],
requirePassword: true,
confirmTitle: 'Delete user?',
confirmText: 'Irreversible.',
);
}
protected function deleteUser(int $id)
{
User::findOrFail($id)->delete();
$this->swalToastSuccess('Deleted');
}
The key discipline: the target method must be protected or private. Livewire only exposes public methods, so a protected target cannot be called from devtools under any circumstances. If you make it public "just to test it", you've undone the entire security model.
When you need a button that isn't wired to a Livewire action (or you want to fire from Alpine state), pre-generate the payload in render():
public function render()
{
return view('livewire.settings', [
'deletePayload' => $this->swalGenerateSecureActionPayload('deleteAccount', ['id' => 5], true),
]);
}
<button onclick="window.swalExecuteSecureAction('{{ $deletePayload }}', true, {
confirmTitle: 'Delete account?'
})">Delete</button>
All helpers are also on window (SwalToast, SwalFire, SwalConfirm, etc.) so you can call them from Alpine x-on:click, vanilla JS, or a <script> in Blade.
If you can't use a CDN: composer require, then vendor:publish both laravel-swal-config and laravel-swal-assets, set 'auto_inject' => false, npm install sweetalert2, and in app.js:
import Swal from 'sweetalert2'
window.Swal = Swal
import './vendor/laravel-swal/swal'
Don't forget @vite([...]) in the layout or nothing loads.
The auto-injection middleware runs on every HTML page. That's convenient but adds ~50ms and a CDN round-trip to every response, including error pages and auth screens. In production, consider self-hosting SWAL via swal_cdn pointing at a local asset, or fully switching to the Vite path.
Never expose a secure-action target as public. swalSecureAction protects the invocation path, not the method visibility. Making deleteUser public so you can test it from a test method is a live bypass. If you need testability, extract the logic to a service and have both the protected method and your test call the service.
APP_KEY rotation invalidates all in-flight payloads. Payloads are signed with the app key, so after a deploy that rotates APP_KEY, users mid-flow will get an invalid-payload error. Expected, but surprising the first time. The payloads also carry an expiry and session ID — if the user's session regenerates (e.g. after login), the payload dies.
The confirm event payload shape is array $payload, not named args. A very common mistake is writing #[On('users.delete')] public function delete($id) and getting null forever. The listener always receives the event params wrapped in a single array; destructure with $payload['id'].
Don't nest event listeners on the same component you're already in. Fire from component A, listen in component A is fine, but if you fire from A to a listener in B via $thenEventTo: 'B', remember B is a separate instance — any state on A (like a selected row) isn't shared. Pass everything you need through $thenParams.
Timer/position options don't work on modals. Toasts accept timer and position (top-end by default); swalFire* modals ignore timer unless you set it explicitly. Don't assume defaults carry over.
For custom JS, use window.Swal in the same tick. The auto-injected script loads SWAL as a deferred CDN script. If you have a Blade <script> that runs inline and calls window.Swal.fire() before the wrapper is ready, you'll get "Swal is not defined". Either wrap in DOMContentLoaded or move your logic into an event handler that Livewire dispatches.
Password verification requires the web guard and an authenticated user. swalVerifyCurrentUserPassword calls Hash::check against auth()->user()->password. On guest routes it silently fails (or worse, throws a null-user error). Guard the call with auth()->check().
Debugging secure actions is opaque by design. When a payload is rejected (expired, session mismatch, class mismatch, signature tampered) the failure is surfaced as a generic "verification failed" toast. For local debugging, add temporary Log::debug() inside your listener and check whether the encrypted round-trip even reaches it. Nine times out of ten the bug is: wrong method: name (case-sensitive, no namespace), params mismatch with the target's signature, or the session ID changed between render and click.
Don't vendor:publish the config unless you need to. If you publish it and later update the package, your published config won't pick up new keys. Only publish when you're actively changing auto_inject or swal_cdn.
For a project with CSP: the auto-injected script is a bare <script src="https://cdn.jsdelivr.net/..."> and the wrapper is inline — you'll need to allow cdn.jsdelivr.net and 'unsafe-inline' in your script-src, or take the Vite path (which is strongly preferred with strict CSP).
How can I help you explore Laravel packages today?