Every notification library ships a design system you did not ask for
This is me
Latest articles
- GDPR cookie consent in Laravel with Wirecookies (opens on another site)
- Localized routes in Laravel with Laralang (opens on another site)
- Collecting user feedback in Laravel with Wirebug (opens on another site)
- The same operation ends up in three places, and none of them agree
- Laravel makes you choose between readable templates and stable translation keys
- Model observers are the wrong home for derived fields
- A coding agent is a generalist, and your codebase is not general
- Translating a Laravel app is two problems, and only one of them is about words
- The tags table is simple until the fourth requirement arrives
Every Livewire app I build eventually needs the same twenty pixels of UI: a green toast after a save, a red one when a request fails. For years I installed whatever notification library was current, and every time it brought a guest.
Tailwind, because the markup is utility classes. Or Bootstrap, for the same reason in a different dialect. Or a runtime: a few dozen kilobytes of framework to animate a rectangle that slides in and fades out.
None of that is incompetence on the library authors' part. A component library has to render something, and the honest options are to ship your own CSS or to depend on somebody's. The trouble is that the second option makes an integration decision on your behalf, and it is the one decision an app has already made for itself before it ever needs a toast.
Themes are variables, not markup
If the package must not depend on your CSS framework, it has to bring its own styling, and then the question is how you override it.
The common answer is publishing views. You run a vendor:publish, get the Blade file in your app, and edit it. It works exactly once: your copy is now forked, and every package update after that is a manual merge of markup you did not write.
So there is nothing to publish for appearance. A theme is a set of custom properties:
:root {
--wt-radius: 6px;
--wt-bg: #fafafa;
--wt-text: #222;
--wt-success: #10b981;
--wt-error: #ef4444;
}
Eleven presets ship with it, and a custom theme is variables in your own stylesheet. No package file is touched, so upgrades stay clean, and the styling lives where a designer can reach it instead of inside a Blade template behind a publish command.
Dark mode falls out of the same approach rather than being a feature: prefers-color-scheme by default, a .dark ancestor class because that is what most Tailwind setups already flip, a mode prop to force it. Three ways in, because an app has usually already picked one and the package should not care which.
One name from three languages
The other decision was about the call site.
A toast gets fired from wherever the thing that succeeded happened: a Livewire method after a save, an Alpine handler on a copy-to-clipboard button, plain JS in a script that predates both. Three contexts, and the usual outcome is three different APIs) a facade in PHP, an event name in Alpine, a global in JS (each documented separately, each remembered wrongly.
So the name is notify from all three:
$this->notify('Saved successfully');
<button @click="$dispatch('notify', { message: 'Saved', type: 'success' })">
window.notify('Saved successfully', 'success');
That is not three implementations kept in sync, which would rot. There is one function. The JS global is that function, and both Livewire and Alpine reach it by firing a notify window event that forwards into it. The PHP side is a thin macro that emits the event, which is why Livewire is optional: remove it and the other two paths are untouched.
Two asset paths that must not both run
The one piece of genuine sharp edge is worth explaining, because it is the kind of thing that produces a confusing bug.
Apps with Vite import the CSS and JS from vendor and bundle them. Apps without a bundler publish the assets to public/ and need the component to emit the tags. Both are legitimate, and doing both means the stylesheet and the script load twice.
The resolution is that the component stays silent by default and only emits tags when asked:
<x-wiretoast /> {{-- bundled: injects nothing --}}
<x-wiretoast :assets="true" /> {{-- published: emits the tags --}}
Defaulting to silence is deliberate. If the default emitted tags, the bundler path (the more common one, and the one where a duplicate is hardest to spot because everything still works) would break quietly. The failure mode of the wrong default should be the loud one: nothing renders, you notice immediately.
Themes, the type list and the dark mode options are on the Wiretoast page. Source on GitHub, package on Packagist.