php-notify/notify-symfony
A Symfony bundle for php-notify/notify that wires the notification library into Symfony’s dependency injection container and configuration, making it easy to send notifications through configured channels from services, controllers, and console commands.
First, the reality check. This is a Symfony bundle, not a Laravel package. If you are working in Laravel, stop here and use php-flasher/flasher-laravel — the API surface (flash(), flasher(), adapters) is nearly identical and the Laravel service provider is the one that hooks into session middleware correctly. Only read on if you maintain a Symfony app alongside your Laravel work, or you are porting notification code between the two.
Minimal steps (Symfony 6/7):
composer require php-notify/notify-symfony
# plus at least one frontend adapter, e.g. toastr/noty/sweetalert
Register the bundle manually — there is no Flex recipe for a read-only split, so bundles.php will not update itself:
// config/bundles.php
return [
// ...
PhpNotify\NotifySymfony\NotifySymfonyBundle::class => ['all' => true],
];
Create the config file and import the adapter assets in your base template (the bundle does not ship CSS/JS for the JS libraries themselves):
# config/packages/notify.yaml
notify:
default: toastr
adapters:
toastr:
options:
position_class: toast-top-right
timeout: 5000
{# templates/base.html.twig #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/toastr/build/toastr.min.css">
<script src="https://cdn.jsdelivr.net/npm/toastr/build/toastr.min.js"></script>
{{ flasher_render() }} {# exactly once, just before </body> #}
Where to look first in the source:
src/DependencyInjection/Configuration.php — the real, authoritative config tree. Docs lag behind it.src/DependencyInjection/NotifySymfonyExtension.php — shows which services are aliased and what the default adapter resolution looks like.src/Resources/views/ — the Twig template behind flasher_render(); this tells you exactly what markup/script is emitted and therefore what your CSP will need.src/Twig/ and any src/helpers.php — confirms whether the global helper is flasher(), flash(), or both in this version.The "hello world" use case — flash after a form submit and survive the redirect:
#[Route('/profile', methods: ['POST'])]
public function update(Request $request): Response
{
// ... persist ...
flasher()->addSuccess('Profile updated.');
return $this->redirectToRoute('profile_show');
}
Because the bundle persists notifications in the session, the redirect works and the toast appears on the next full page render. That session round-trip is the feature — everything else is styling.
Use the injected service, not the helper, in anything you unit test. The global helper is convenient in controllers but untestable without booting the kernel. Inject the interface instead:
use Flasher\Prime\FlasherInterface;
public function __construct(private readonly FlasherInterface $flasher) {}
public function delete(Post $post): Response
{
$this->flasher->addSuccess('Post deleted.');
return $this->redirectToRoute('posts_index');
}
Alias the concrete class to the interface in services.yaml if the bundle only registers the concrete one — check NotifySymfonyExtension first, it usually already does this.
Chained options for one-off customization. Don't create a preset for a single call:
flasher()
->addInfo('Sync started.')
->option('timeout', 3000)
->option('progressBar', true)
->preset('toastr');
Presets for anything used twice or more. Presets live in YAML and keep controllers free of magic strings:
notify:
presets:
form_error:
type: error
options:
timeout: 0
closeButton: true
position_class: toast-bottom-left
flasher()->preset('form_error')->addError('Validation failed.');
Form error bridging. The most common day-to-day integration is turning a FormInterface into a single notification instead of re-rendering an error block:
if (!$form->isSubmitted() || !$form->isValid()) {
foreach ($form->getErrors(true) as $error) {
flasher()->addError($error->getMessage());
}
}
For high-volume forms this floods the UI — cap it at one summary message and keep field-level errors in Twig.
Global notifications via event subscribers. Login/logout, security failures, and deprecation warnings are best handled outside controllers:
#[AsEventListener(event: LoginSuccessEvent::class)]
final class LoginNotification
{
public function __construct(private readonly FlasherInterface $flasher) {}
public function __invoke(LoginSuccessEvent $event): void
{
$this->flasher->addSuccess(sprintf('Welcome back, %s.', $event->getUser()->getUserIdentifier()));
}
}
The same pattern works for KernelEvents::RESPONSE if you want to attach notifications based on status codes — but see the CSP gotcha below before you make this global.
SPA / API mode. For anything returning JSON, skip rendering and hand the envelope to your frontend:
return $this->json([
'data' => $post,
'notifications' => flasher()->render(), // or getEnvelope() depending on version
]);
Then call your adapter's JS API client-side. This is the correct path for a stateless Symfony API — the session-backed renderer is a poor fit there.
Testing assertions. The flasher accumulates notifications in memory before persisting, so you can assert directly in a functional test without parsing HTML:
self::assertNotEmpty(flasher()->getNotifications());
Reset state between assertions (flasher()->clear() if available) or a shared container will leak messages across assertions.
The read-only split is a trap for contributors, not consumers. This repo is a subtree split of a monorepo. The 13 stars reflect the mirror, not adoption. Consequences:
php-notify/notify-symfony with a differently-versioned core is the #1 cause of Call to undefined method ...::preset() and addSuccess() no-ops.No Flex recipe means no auto-registration and no auto-config. If you copy a config/packages/notify.yaml from a tutorial without registering the bundle, Symfony silently ignores the whole file — no error, just no notifications. Worse, in test and dev environments with separate bundles.php conditions, the Twig extension won't be registered and flasher_render() throws "Unknown function." Always clear the config cache after touching the YAML:
bin/console cache:clear && bin/console debug:container flasher
Session is a hard dependency. Stateless firewalls, stateless: true on your json_login, or an API-only route with no session started means notifications accumulate in memory and are never flushed — they vanish at the end of the request. If you see "the toast works in the browser but not after a redirect," check that the target route isn't behind a stateless firewall.
Rendering twice duplicates every toast. flasher_render() in both base.html.twig and a child template is a classic. So is Turbo/Turbo Drive or htmx swapping <body> without executing the inline script — the notifications get consumed from session but never displayed, then appear (once) on the next full load. With Turbo, subscribe to turbo:load and re-run the adapter init, or move rendering into your own controller of the envelope endpoint.
Adapter assets are your responsibility. The bundle emits a script that calls toastr.success(...) — it does not guarantee toastr exists. Missing CDN include gives toastr is not defined in the console with no server-side error. With Webpack Encore or Symfony AssetMapper, import the adapter JS in your app.js entry and make sure it lands before the inline render script (Encore's script tags render in <head> by default, which is fine, but defer can race).
CSP will bite you. flasher_render() emits an inline <script>. Under a strict Content-Security-Policy without 'unsafe-inline', it is blocked and you get silent failures. Options: add a nonce (requires a custom template override in templates/bundles/NotifySymfonyBundle/), or switch to the JSON-envelope pattern and render client-side. Prefer the latter — overriding vendor templates is a maintenance tax across minor upgrades.
Config quirks worth knowing:
default: selects the adapter globally, but per-call ->preset() and ->adapter() override it. If a preset names an adapter that doesn't exist in adapters:, you get a confusing "plugin not found" at render time, not at container build time."false" as a string is truthy in JavaScript. Quote carefully.notify: here) must match the bundle extension's alias. If you rebrand the package or use the upstream flasher: key, the extension won't load your file — check NotifySymfonyExtension::getAlias().Debugging checklist, in order: bin/console debug:container --tag=twig.extension | grep -i flash (is the Twig extension registered?) → dump(flasher()->getNotifications()) at the end of the controller (did anything get added?) → inspect the session (_flasher / flasher key depending on version) after the redirect (did it survive?) → view-source on the rendered page (was the script emitted?) → browser console (did the adapter exist?).
Extension points if the bundle doesn't fit: implement the adapter plugin interface to wrap a JS library the bundle doesn't ship; decorate the storage service to log notifications instead of session-persisting them in test; or register a custom NotificationFactory if you need typed notification objects instead of fluent strings. All three are stable seams — but verify the interface signatures against your installed version before writing, since the split's docs lag the code.
Bottom line for a Laravel developer: treat this as a reference implementation. The API you'd actually write against is php-flasher/flasher-laravel, and roughly 80% of the patterns above (presets, chaining, form-error bridging, event-listener notifications, JSON envelopes) translate directly. The Symfony-specific friction — manual bundle registration, services.yaml aliasing, Twig extension — is work you simply don't do in Laravel, where the service provider and Blade @flasher directive handle it.
How can I help you explore Laravel packages today?