langsys / langsys-php-laravel
Laravel integration for the Langsys translation SDK — service provider, @t Blade directive, locale-detection middleware, Livewire support, and Inertia SSR seeding over langsys/langsys-php.
Requires
- php: ^8.1
- ext-intl: *
- illuminate/contracts: ^10.0 || ^11.0 || ^12.0
- illuminate/support: ^10.0 || ^11.0 || ^12.0
- langsys/langsys-php: ^1.3
Requires (Dev)
- livewire/livewire: ^3.0
- orchestra/testbench: ^8.22 || ^9.0 || ^10.0
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
- inertiajs/inertia-laravel: Seed the JS SDKs' initialTranslations via InertiaSsrProps::share()
- livewire/livewire: Translations inside Livewire components re-resolve on every update through the same middleware
This package is auto-updated.
Last update: 2026-08-16 18:18:59 UTC
README
Langsys revolutionizes localization for apps with easy to integrate, realtime, continuous translations. Read more about Langsys Translation Manager at the website.
Integrate the Langsys Translation Manager into your Laravel application — Blade, Livewire, Alpine's server-rendered content, and Inertia SSR seeding for the JS SDKs.
Requirements
- PHP 8.1+, Laravel 10, 11, or 12
ext-intl(required — ICU plural rules and locale-aware number/date formatting). Note that the officialphp:8.x-fpmDocker images do not bundle it; adddocker-php-ext-install intl.
How it's layered
langsys/langsys-php-laravel is a Laravel wrapper over the dependency-free langsys/langsys-php — which owns the HTTP client, phrase lookup, placeholder interpolation, token discovery/queueing, and catalog caching. This package adds only the Laravel-native concerns:
- A service provider that builds the SDK
Clientfromconfig/langsys.phpand routes catalog caching through Laravel's cache (any store — redis, memcached, file, array) - A
t()helper and@tBlade directive exposing the SDK's{name}interpolation and ICU pluralization to Blade — the same phrase syntax as the Langsys JS SDKs, so one catalog serves your whole stack - A
DetectLocalemiddleware resolving the request locale (query → cookie → session →Accept-Language) - A
FlushPendingRegistrationsterminable middleware (plus an Octane listener) that sends newly discovered phrases to Langsys after the response - An opt-in
TranslateResponsemiddleware that translates whole rendered HTML responses, for projects that want coverage without hand-tagging - An
InertiaSsrPropshelper that seeds the JS SDKs'initialTranslationsfor SSR handoff
Install
composer require langsys/langsys-php-laravel php artisan vendor:publish --tag=langsys-config
Set your credentials in .env:
LANGSYS_API_KEY=your-api-key LANGSYS_PROJECT_ID=your-project-id
API key permissions
- Write key (development): phrases rendered through
t()/@tthat aren't in the catalog yet are queued and auto-registered at the end of the request. - Read-only key (production): lookups only — no token creation.
The key type is detected server-side; there is no local toggle.
Setup
Add the middleware to your web group (or per-route via the langsys.locale / langsys.flush aliases):
// Laravel 11/12: bootstrap/app.php ->withMiddleware(function (Middleware $middleware) { $middleware->web(append: [ \Langsys\Laravel\Http\Middleware\DetectLocale::class, \Langsys\Laravel\Http\Middleware\FlushPendingRegistrations::class, ]); })
Using translations
t() and @t — the everyday API
The signature mirrors the JS SDKs: t($phrase, $category?, $params?, $locale?). The phrase is both the lookup key and the base-language default — no keys file.
<h1>@t('Welcome to my app', 'UI')</h1> <p>@t('Hello, {name}! You have {count} new messages.', 'Greetings', ['name' => $name, 'count' => $count])</p>
// Controllers, Livewire components, jobs, mail — anywhere: $title = t('Order confirmed', 'Checkout'); $body = t('Your order {id} ships on {date}.', 'Checkout', ['id' => (string) $order->number, 'date' => $order->ships_at]);
Output through @t is HTML-escaped like {{ }}.
Interpolation & pluralization
{name} placeholders use the same syntax as the JS SDKs, so the same phrase translates once and renders everywhere. Numbers and DateTimeInterface values are CLDR-formatted for the target locale (pass strings to opt out), and ICU MessageFormat pluralization gives correct categories per language:
t('{count, plural, one {# item} other {# items}}', 'Cart', ['count' => $count]);
Unknown placeholders are left visible rather than rendering empty.
Always pass values as $params — never build the string first. t(sprintf('Hello, %s!', $name)) registers a brand-new catalog phrase for every distinct runtime value, polluting the catalog that every Langsys SDK shares. Passing $params keeps one entry (Hello, {name}!) serving every value: the raw placeholder-bearing phrase is what gets registered, and only what you receive is interpolated.
Interpolation itself lives in langsys/langsys-php, so Blade, plain PHP, and the JS SDKs all render a shared phrase identically.
Categorization disambiguates context
<strong>@t('Home', 'Main Menu')</strong> {{-- "Inicio" --}} <strong>@t('Home', 'Home repairs')</strong> {{-- "Hogar" --}}
Locale detection
DetectLocale tries the configured sources in order (query, cookie, session, header by default), canonicalizes the winner to BCP 47, and sets it on both the Laravel app and the Langsys client. Accept-Language parsing is delegated to the SDK's LocaleDetector::fromAcceptLanguage(), so it honours q-value priority (en,es-MX;q=0.9 resolves to en), rejects q=0 as "not acceptable" per RFC 7231, and fills a missing region (en → en-EN), since the Langsys API addresses translations by xx-yy codes. An explicit ?locale=es-ES choice persists via cookie (or session — see config/langsys.php). The locale cookie is exempted from cookie encryption so client-side JS can share the preference.
Token discovery & flushing
With a write key, phrases that miss the catalog are queued in-memory during the request and flushed to Langsys after the response is sent — by the terminable middleware under PHP-FPM, and by a RequestTerminated listener under Octane (where PHP's shutdown handlers never fire between requests). Read-only keys skip the flush silently. Disable with LANGSYS_AUTO_FLUSH=false and flush manually via app(\Langsys\SDK\Client::class)->flushPendingRegistrations().
Livewire
Nothing extra to configure: Livewire's AJAX updates run through the same web middleware, so t() inside components resolves in the page's locale and newly discovered phrases flush after every update. Verified end-to-end in tests/LivewireSupportTest.php — a real component renders @t with interpolation, and a phrase that only appears after an interaction is discovered on the Livewire update and drained by the flush middleware.
class Checkout extends Component { public function getTitleProperty(): string { return t('Review your order', 'Checkout'); } }
Automatic translation — the langsys.translate-page middleware
Everything above is tagged mode: coverage equals your tagging. TranslateResponse is the automatic mode — it runs the SDK's page translator over the rendered HTML, translating every text node and translatable attribute (placeholder, alt, aria-label, …) with no @t at all. It's the only way to cover text Alpine injects from a JS expression, which never becomes a DOM node you can wrap:
<span x-text="'Save changes'"></span> <button :aria-label="open ? 'Collapse' : 'Expand'">…</button>
Pick one mode per project — never run automatic mode and
@ttogether. If both run, this middleware re-walks nodes@talready translated, looks the translated string up as a source phrase, misses, and registers it. A Spanish"Guardar"then enters the catalog every Langsys SDK shares as though it were source text. Mark any already-resolved subtreetranslate="no".
It is opt-in and applies to nothing until you attach it:
// routes/web.php — per route or group, never global Route::middleware('langsys.translate-page')->group(function () { Route::get('/', HomeController::class); });
LANGSYS_TRANSLATE_RESPONSE=true
Only text/html responses are touched. JSON, redirects, streamed and file responses pass through untouched — which is what keeps Livewire and Inertia XHR round-trips out automatically. Scope it further with only / except path patterns in config/langsys.php (except wins), and set a category to namespace everything the page registers.
Newly discovered phrases are queued and flushed after the response, exactly as they are for t() / @t — the same terminable middleware and Octane listener drain both. Requires langsys/langsys-php ^1.3; before that, page registration made blocking HTTP calls mid-render.
Caching is off by default and should usually stay off. Translated output is keyed by a hash of the source HTML, so a page carrying a CSRF token or a timestamp produces a new key every render. Enable it only for genuinely static, high-traffic HTML.
If you server-render with this and hydrate with a Langsys JS SDK, use
langsys-js-typescript0.6.2 or newer (or a framework SDK built on it). Its tokenizer skipsdata-langsys-phrasewith semantics matching the PHP side; earlier versions re-walk server-tokenized subtrees and split phrases at tag boundaries —Read the <a>docs</a> nowregisters as three fragments — fragmenting the shared catalog silently and putting a count in a different phrase from the noun it inflects. 0.6.0 and 0.6.1 honour the marker only partially, and fail silently when they don't.
Excluding a subtree
Two markers, and the difference is not cosmetic:
<pre translate="no">composer require langsys/langsys-php-laravel</pre> {{-- nobody translates this --}} <div data-notrans>{{ $userSuppliedHtml }}</div> {{-- Langsys skips it --}}
translate="no"is standards HTML, so browser translation features (Chrome, Safari, Edge "translate this page") honour it too. Use it for code samples, brand names, identifiers — anything no translator, human or machine, should touch.data-notransexcludes the subtree from Langsys only; a reader's browser-translation is still free to act on it. Use it for content that's already resolved, or that you handle yourself.
Presence is intent for both: a bare data-notrans excludes, and only ="false" or ="0" opts back in (trimmed, case-insensitive).
Inertia SSR seeding (Vue/React/Svelte SDKs)
Hand the server-fetched catalog to the JS SDK so the client skips its initial fetch:
// app/Http/Middleware/HandleInertiaRequests.php use Langsys\Laravel\Support\InertiaSsrProps; public function share(Request $request): array { return [...parent::share($request), ...InertiaSsrProps::share()]; }
// resources/js — Vue example (same shape for React/Svelte) import { LangsysApp, useLocaleStore } from 'langsys-js-vue'; const { store } = useLocaleStore(props.langsys.initialTranslationsLocale); LangsysApp.init({ projectid: import.meta.env.VITE_LANGSYS_PROJECT_ID, key: import.meta.env.VITE_LANGSYS_API_KEY, // read-only key on the client UserLocaleStore: store, initialTranslations: props.langsys.initialTranslations, initialTranslationsLocale: props.langsys.initialTranslationsLocale, });
The facade and the raw client
use Langsys\Laravel\Facades\Langsys; Langsys::translate('Save', 'UI'); Langsys::client()->getTranslations('es-es'); // vanilla langsys/langsys-php Client Langsys::client()->translatePage($html); // full-page HTML translation
Configuration reference
See config/langsys.php: credentials (LANGSYS_API_KEY, LANGSYS_PROJECT_ID, LANGSYS_API_URL), catalog cache (Laravel store/prefix/TTL), locale-detection sources and persistence, and auto_flush.
Testing your app
Bind a fake client so tests never hit the API:
$this->app->instance(\Langsys\SDK\Client::class, $yourFakeClient);
This package's own suite (composer test) shows a complete FakeClient pattern in tests/Fakes/FakeClient.php.
License
MIT © Langsys