survos/js-twig-bundle
A Symfony bundle from Survos that streamlines integrating JavaScript into Twig templates, with helpers and configuration for managing scripts, assets, and dynamic front-end behavior alongside your Twig views.
First, calibrate expectations. survos/js-twig-bundle is a Symfony bundle, not a Laravel package. There is no service provider, no config/js_twig.php, no Blade integration, and no Vite story — it targets Symfony's AssetMapper (importmaps, no build step) plus symfony/stimulus-bundle and symfony/ux-icons. If your Laravel app is your only PHP surface, you will not composer require this and get value; you will instead either (a) borrow the pattern, or (b) use it in a Symfony-based admin/backoffice that sits next to your Laravel app.
The value proposition is genuinely useful and has a clean Laravel analogue: one template definition, rendered server-side and client-side, so you stop hand-writing the same markup twice in Blade/Twig and in JS template literals.
Mental map for a Laravel dev:
| Symfony helper | Laravel equivalent |
|---|---|
path() in JS |
Ziggy's route() |
stimulus_controller() / stimulus_action() |
none first-party |
ux_icon() |
blade-ui-kit/blade-icons |
| AssetMapper + importmap | Vite + @vite |
Where to look first (in order):
twig-browser — that is where path(), stimulus_*() and ux_icon() get registered as Twig.js functions.path() works in the browser.{% block %} — that is the unit of reuse.Minimal wiring (Symfony side, roughly):
composer require survos/js-twig-bundle
php bin/console importmap:install
php bin/console importmap:require twig-browser
Register the bundle in config/bundles.php, then confirm the route JSON is reachable from the browser (view source / network tab) before writing any rendering code. That single check tells you whether AssetMapper is wired correctly or not.
First use case to ship: a table row or card that the server renders initially, and that the client re-renders after a fetch — same block, same context shape. Not a whole page. Fragments only.
{# templates/_row.html.twig #}
{% block row %}
<tr data-id="{{ item.id }}">
<td>{{ item.name|e }}</td>
<td>{{ ux_icon('bi:pencil') }}</td>
<td>
<button {{ stimulus_action('row', 'edit', 'click') }}>Edit</button>
<a href="{{ path('item_show', {id: item.id}) }}">View</a>
</td>
</tr>
{% endblock %}
1. Block-as-fragment (the core pattern). Load the template source into twig-browser, render a named block with a context object, and swap the DOM node. The template string is the contract; the context is the data contract. Keep both stable and everything else is plumbing.
import { controller } from '@hotwired/stimulus';
// import shape depends on the twig-browser entry the bundle maps; check importmap.php
export default class extends controller {
static targets = ['body'];
async refresh(item) {
const res = await fetch('/api/items/' + item.id);
const ctx = await res.json();
this.bodyTarget.innerHTML = this.twig.render('_row.html.twig', 'row', { item: ctx });
}
}
2. One context normalizer, owned by PHP. The biggest source of bugs is PHP-side data (entities, DateTime, enums) reaching JS in a different shape than the Twig server render expected. Define a single serializer that emits plain scalars/ISO strings, and use it for both the full-page render and the JSON endpoint. If the server render gets a DateTime object and the client render gets a string, your |date() filter will quietly diverge.
3. Routes as data, never hardcoded. path() in the browser should mirror the Symfony router exactly. Fetch it once, cache it in a module-level singleton, and let a missing route name throw loudly rather than emitting /undefined. This is the same discipline Ziggy forces on Laravel teams — treat route generation as a build artifact, not a string you type.
4. stimulus_* helpers are what make client-rendered markup actually work. Hand-writing data-controller="row" in a JS template literal is where teams drift. Render the attributes through the helpers so freshly-inserted DOM carries valid data-action/data-*-target attributes. Stimulus picks up inserted nodes via its MutationObserver, so newly rendered fragments are connected automatically — but only if the attributes are correct and the controller identifier is registered.
5. Server-first, client-second. Always render the fragment on the server on first paint; use twig-browser only for subsequent updates. This keeps SEO, no-JS behaviour, and time-to-content intact, and it gives you a free fallback if Twig.js throws.
6. Laravel-side pattern extraction (if you're not on Symfony). The transferable ideas are: (a) an endpoint that returns structured context rather than HTML, plus (b) a client-side templating step. The Laravel-native way to get 80% of this is a JSON route manifest (Ziggy) plus a single re-render endpoint returning context for an Alpine/Blade::render-style partial, or simply returning view('partials.row', $ctx)->render() and letting the server do the rendering. That last option eliminates the whole dual-runtime problem — seriously consider it before porting Twig.js into a Laravel app.
7. Progressive rollout. Migrate one interactive list at a time. Each migration is: extract block → add JSON endpoint → add Stimulus controller → delete the JS string-template version. Never leave both in place.
Twig.js is not Twig. Missing filters/functions, no server-side extension support, different attribute() semantics, different macro and whitespace handling, and autoescaping that you must verify rather than assume. Keep the shared templates tiny and dumb: no include, no embed, no custom filters, no inheritance chains. If a template needs a Twig feature Twig.js doesn't have, that template is not a shared template.
Escaping is your XSS boundary. Two distinct injection surfaces: the context (safe — Twig escapes it) and the template source (catastrophic — never interpolate user data into template strings). Any "dynamic template from the database" feature is an XSS CVE waiting to happen.
AssetMapper has a dev/prod split. In dev, assets are served dynamically; in prod you must run asset-map:compile. Teams routinely ship a working dev environment and a broken prod one because the route JSON or twig-browser entry was never compiled. Add a smoke test against the compiled output.
Template source changes need cache-busting. If you fetch template sources over HTTP, a redeploy that changes a block will keep serving the stale version. Version the template URL or include the asset digest.
The release notes don't match the package. 1.6.0 is documented only as "Hack (not via survos) to remove dumps" — a hygiene patch, not a feature. That has two consequences: (a) you cannot infer the API from the changelog and must read the source, and (b) it is a maintenance-risk signal. Pin the version exactly, review the diff before upgrading, and add a CI gate (grep -rn "dump(\|dd(\|var_dump(" src/ templates/) so a future re-introduction of debug calls is caught rather than shipped. Zero stars, single-maintainer, and release notes describing a "hack" outside the upstream project are all reasons to treat this as a dev-only, non-critical dependency with a fallback plan.
Debugging Twig.js failures is painful. Errors surface as opaque render-time exceptions. Wrap the render call, and on failure log the template name, the block name, and the full context object — without that, you will be bisecting JSON payloads by hand.
Extension points worth knowing: custom JS functions/filters registered on the twig-browser instance, a custom route provider (if you need extra routes or non-Symfony URLs in path()), and the Stimulus controller that orchestrates hydration. Prefer registering a new JS function over forking the bundle.
Don't move the page to the client. The temptation after the first successful fragment is to render a whole view in the browser "since Twig works both places." That trades a working server-rendered page for hydration bugs, flash-of-empty-content, and a second templating runtime to debug. Fragments only.
How can I help you explore Laravel packages today?