nowo-tech/hot-reload-bundle
Symfony bundle that brings hot reloading to your development workflow. It automatically refreshes the browser when templates, assets, or PHP files change, so you can iterate faster and skip manual reloads. Lightweight and easy to install.
Install and register (5 minutes)
composer require --dev nowo-tech/hot-reload-bundle
There is no Flex recipe and zero dependents in the wild, so register the bundle manually in config/bundles.php (dev/test only is fine — it is a dev convenience):
Nowo\HotReloadBundle\HotReloadBundle::class => ['dev' => true, 'test' => true],
Turn it on where FrankenPHP runs. The bundle's contract is "inject the client when FRANKENPHP_HOT_RELOAD is available", so the switch is an environment variable on the PHP process, not a Symfony config flag:
# compose.yaml — the FrankenPHP service only
services:
php:
image: dunglas/frankenphp
environment:
FRANKENPHP_HOT_RELOAD: '1'
MERCURE_URL: 'http://localhost:4040/.well-known/mercure'
volumes:
- .:/app
Look here first, in this order:
src/DependencyInjection/Configuration.php — the real root key (likely nowo_tech_hot_reload) and which knobs exist (enabled flag, Mercure URL, topic, injection target). php bin/console config:dump-reference <root_key> after installing.kernel.response listener/subscriber — this is the whole feature. Read exactly what it checks before injecting.src/Resources/ — the template/snippet it appends and where it appends it (</body>, </head>).First use case: start FrankenPHP in worker mode with the watcher, open a page, edit a Twig template, and confirm the browser reloads without a manual refresh. If nothing happens, curl -s http://localhost/ | grep -i -E 'mercure|hot.?reload' tells you immediately whether the injection fired — that one command splits the problem in half.
Treat it as dev-only infrastructure, not application code. The cleanest pattern is to leave every template untouched and let the bundle do the injection. Resist the temptation to also add {% if app.debug %} guards around it — you then have two sources of truth and the env var becomes decorative.
Gate it at the container level, per environment. Put FRANKENPHP_HOT_RELOAD in your local compose.override.yaml (or .env.local) and never in the prod image. Because the bundle keys off process env rather than kernel.environment, a stray variable in a shared .env will happily inject the client in staging.
Keep it out of the asset pipeline. The hot reload client comes from FrankenPHP's Mercure hub, not from AssetMapper/Encore. Do not try to import it, version it, or add it to importmap.php — you will fight the bundle and lose.
Verify both states in CI. A cheap, high-value smoke test:
// no FRANKENPHP_HOT_RELOAD → no injected script
$client->request('GET', '/');
self::assertStringNotContainsString('mercure', $client->getResponse()->getContent());
Then run the same assertion inverted with the env var set via putenv() in the test — if the bundle reads env at container build time you will discover that here rather than in a demo.
Coexist with other response-level tooling. If you have listeners adding the Web Debug Toolbar, CSP nonces, or analytics snippets, confirm ordering. A listener that appends before </body> will land adjacent to the toolbar; check which one wins in the rendered output.
For live-components/Turbo pages, a full page reload is a blunt instrument. If the bundle exposes the reload trigger as a JS event or a configurable snippet, hook into it; otherwise scope hot reload to the pages you are actively editing (e.g. by decorating the listener and short-circuiting on non-/_fragment or non-app routes).
FRANKENPHP_HOT_RELOAD=0 or an empty string disables injection depends on the check in the listener. Read that line before trusting it; if it uses getenv() !== false, an empty value may still enable it.clear_env in php-fpm-style configs, or when variables_order omits E, getenv() can return false even though Docker sets the variable. $_SERVER/$_ENV are more reliable in containerised setups — compare with what the bundle actually reads.%env()% is resolved at container compile time unless the env processor is marked runtime. If you enable hot reload and see nothing, run bin/console cache:clear before assuming a bug — then check whether a container rebuild is needed.<script>, any restrictive Content-Security-Policy (script-src 'self') silently kills it. Check the browser console; there is no PHP-side error. Prefer serving from an allowed origin, or add a nonce if the bundle supports it.https:// + Mercure hub on http://localhost:4040 = a blocked EventSource with only a console warning. Terminate TLS consistently or point the hub URL at the same scheme.str_replace('</body>', ...), responses without a closing </body> (streamed responses, JSON, fragments) are skipped — which is usually correct, but confirm StreamedResponse and BinaryFileResponse are excluded rather than accidentally stringified.final by design in this bundle family. You probably cannot extend the listener. Override via service ID in services.yaml (replace the definition) or decorate through the DI container, not by inheritance.composer.json, keep it in require-dev, and read the source before relying on it — it is small enough that the whole feature fits in a couple of files, so the cost of verifying behaviour is minutes, not hours.How can I help you explore Laravel packages today?