funddy/jstranslations-bundle
A Symfony bundle that exposes application translations to JavaScript, letting front-end code reuse the same translation catalog as server-side templates. It helps keep client-side and server-side messages in sync with minimal setup and configuration.
Reality check first: funddy/jstranslations-bundle is a Symfony bundle, not a Laravel package. It registers itself through Symfony's AppKernel/DI container and ships a Bundle class, a DI extension, and (typically) a console command or controller that dumps the Symfony translator catalogue into JavaScript. Running composer require funddy/jstranslations-bundle inside a Laravel app gets you files in vendor/, not a working feature — there is no service provider, no facade, no config publish. With 8 stars, 0 dependents, and a nearly decade-old code base, treat it as a reference implementation of a pattern, not a dependency you can install and forget.
What v2.0.3 changes: the release note is a single line — "Update Translator interface to lastest Symfony version". In practice that means the bundle's shipped JavaScript translator now mirrors the modern Symfony translator contract: trans(id, parameters, domain, locale) with pluralisation driven by a %count% parameter, and transChoice() dropped. It is a breaking change for any client-side JS written against the old interface, and it does nothing to make the package Laravel-compatible — no service provider, no facade, no config. Your install decision is unchanged; only the shape of the helper you copy from it has moved.
If you are working in a legacy Symfony app that already uses it, look at these three things first:
Funddy\JsTranslationsBundle\...Bundle) and how it is registered in app/AppKernel.php.services.xml — this tells you whether translations are exposed via a route, a dumped asset, or both.trans(id, params, domain, locale) global and no longer exposes transChoice; check how placeholders, %count%, and domains are resolved on the client.Your first Laravel use case is the same job the bundle does: make lang/{locale}/*.php available to front-end code without an AJAX round-trip per key. The shortest path is a small Artisan command that flattens the catalogue and writes a JS/JSON file consumed by Vite or a <script> tag.
// app/Console/Commands/DumpJsTranslations.php
public function handle(): int
{
$locale = config('app.locale');
$lines = collect(glob(lang_path("{$locale}/*.php")))
->flatMap(fn ($file) => [
basename($file, '.php') => require $file, // "messages" => [...]
])
->all();
file_put_contents(
public_path("js/translations.{$locale}.js"),
'window.__translations = '.json_encode($lines, JSON_UNESCAPED_UNICODE).';'
);
return self::SUCCESS;
}
Call it from your deploy script (php artisan translations:dump) and include the file before your app bundle.
Two delivery models, pick one deliberately.
response()->json($catalogue)->header('Cache-Control', 'public, max-age=3600'), loaded before first render.Keep the JS call signature close to the PHP one — and to v2.0.3's shape. The bundle's whole value is that the client helper feels like $translator->trans('key', ['%name%' => 'x'], 'messages'). The new release aligns that interface with current Symfony, so if you are porting or copying the helper, adopt the four-argument form rather than the old two-argument shortcut:
// mirrors v2.0.3: trans(id, params = {}, domain = 'messages', locale = null)
window.app = window.app || {};
window.app.trans = (id, params = {}, domain = 'messages', locale = null) => {
const catalogue = locale ? window.__translations?.[locale] : window.__translations;
let raw = catalogue?.[domain]?.[id] ?? id;
// pluralisation: %count% in params selects the form, Symfony-style
if ('%count%' in params) {
const forms = raw.split('|');
const n = Number(params['%count%']);
raw = forms.length === 1 ? forms[0] : (n === 1 ? forms[0] : forms[1]);
}
return raw.replace(/%(\w+)%/g, (_, k) => params[k] ?? `%${k}%`)
.replace(/:(\w+)/g, (_, k) => params[k] ?? `:${k}`);
};
Note the placeholder gap: Symfony uses %name%, Laravel uses :name. If you port JS written against the bundle, either normalise keys at dump time or accept both as above. Half-migrated placeholders that silently render as literal %name% (or :name) is the single most common regression in this kind of port — and v2.0.3 makes it more likely, because copy-pasted snippets from older blog posts will still call transChoice() and now simply throw TypeError: transChoice is not a function.
Group by domain, not by one giant blob. The bundle maps Symfony's domain concept onto a nested object. Mirror that with Laravel's file-per-group convention (messages.php, validation.php, auth.php) so a component can ship only the groups it needs. For large SPAs, dump one file per domain and lazy-load with dynamic import().
Interpolate after escaping. Build the string on the server or in the helper, then let the view layer decide whether it lands in textContent or innerHTML. Never concatenate raw user input into a translation string and inject it as HTML — translated strings are trusted, the parameters usually are not.
Locale handling. Resolve the locale from app()->getLocale() (or the request's Accept-Language if you set it that way) and generate only that locale's bundle. The new optional locale argument on the client interface is for multi-locale pages and tests — it is not a licence to ship every locale to every visitor, which remains the most common performance mistake in hand-rolled ports of this pattern.
symfony/* packages at far newer majors, so Composer will either refuse to resolve or drag in a conflicting tree. Do not try to force it with --ignore-platform-reqs; you will break your framework.transChoice() is gone; pluralisation now goes through trans(id, {'%count%': n}, domain). Grep your front-end for transChoice before you copy the new runtime in, and note the interface change is not covered by any PHP-side deprecation shim — it fails at call time, in the browser.@trans or Translator in your views has to be written by you. v2.0.3 adds none of this.Translator). That name is generic enough to clash with other libraries. Namespace your own helper (window.app.trans) rather than copying the global — the release does not namespace it for you.lang/en/messages.php but not re-dumped simply falls back to the raw key on the client, with no error. Add the dump command to your deploy pipeline and, in local dev, either re-run it on file change or use the runtime-endpoint mode instead.undefined rendering as an empty string in the UI and spend an afternoon on it.JSON_UNESCAPED_UNICODE (and JSON_UNESCAPED_SLASHES if your strings contain URLs). Without it, non-Latin locales arrive as \uXXXX escapes — functionally fine, but painful to debug in the network tab.%count% convention v2.0.3 expects. If you use ICU/MessageFormatter on the server, a naive replace() or a two-form | split will silently produce the wrong plural form for locales with three or more forms (Polish, Russian, Arabic).translations.en.abc123.js) or append ?v=filemtime(...) so browsers do not serve last release's strings — doubly important now that a dependency bump can change the runtime contract under an unchanged URL.How can I help you explore Laravel packages today?