webx-ui/localization
Webx UI Localization brings localization tools to your Laravel UI. It offers helpers and components for managing translations, switching locales, and keeping multilingual interfaces consistent, so you can ship apps that feel native across languages with less effort.
Treat this as the localization layer of the webx-ui platform, not a standalone library. It assumes sibling packages exist (module-admin for translation endpoints, routing for translated slugs, cache for invalidation), so adopt it in that context. Nothing here will fail loudly on install — the Locales service falls back to config('webx-localization.locales') until the table is migrated, which makes phased rollouts safe.
Minimal path in one sitting:
composer require webx-ui/localization
php artisan vendor:publish --tag=webx-localization-config
# define the locales you ship in config/webx-localization.php
php artisan webx:locales:seed
Then register the middleware aliases the service provider exposes:
webx.locale — resolves the current site locale (prefix, header, or none, per 'strategy')webx.panel-locale — resolves webx.panel-locale independently of webx.localeOneSpellingPerAddress — the route guard that 404s unknown prefixes and 301s the default prefix to the unprefixed URL (unless prefix_default is on)Stack them on your public route group in the order above, then on the admin group with the panel alias.
Where to look first, in order:
config/webx-localization.php — 'locales', 'strategy', 'fallback', 'panel', 'namespaces', cache flag. Everything you'll actually change lives here.Locales service — codes(), defaultCode(), current(), use(), chain(), panel(), seed(), forget(). This is your day-to-day API surface.HasTranslations trait — the read/write contract for translated attributes.First use case — one translated model, one page. Add $table->translatable('title', 'body') to a migration (it creates nullable JSON columns), use HasTranslations on the model, declare the translated attributes, then write a map and read it back in the current locale:
$page->title = ['en' => 'About', 'uk' => 'Про нас'];
$page->save();
$page->forLocale('uk')->title; // 'Про нас'
$page->forLocale('en')->title; // 'About'
That's the loop. Everything else — fallback, scopes, panel dictionaries — composes on top of it.
The storage model is the decision. HasTranslations writes {"en": "...", "uk": "..."} into a single nullable JSON column per attribute. No joins, no per-locale schema churn, reads stay single-row. Accept that you are trading away per-language indexes and DB-level uniqueness. Make that call deliberately before the first translated row exists — moving to per-locale tables later is a data migration, not a config change.
Migration and backfill pattern. For brownfield, add the translatable columns nullable, backfill single-language values into a map keyed by your default code, then flip reads:
DB::table('pages')->update([
'title' => DB::raw("JSON_OBJECT('en', title)"),
]);
Run a dual-read window in staging against forLocale() / getTranslations() before switching production reads. dropTranslatable() exists for the reverse.
Querying translated fields. Use the provided scopes rather than reaching into JSON with raw SQL:
Page::whereTranslation('title', 'About')->get();
Page::whereTranslationLike('title', '%about%')->get();
Page::orderByTranslation('title')->get();
These exist so you don't hand-roll JSON_EXTRACT, but they are application-level scopes — they will not match native-column performance at volume. Front read-heavy public pages with cache rather than optimizing the scope.
Locale resolution is middleware config, not code. 'strategy' => 'prefix' | 'header' | 'none' picks how the request locale is found. Prefix is the SEO-relevant one; it pairs with OneSpellingPerAddress to make unknown prefixes a 404 and the default prefix a 301 to the unprefixed address. That is a tested invariant — don't reimplement it, and decide your canonical-URL policy before launch, because flipping prefix_default after launch means permanent redirects for indexed URLs.
Keep panel and site locales separate. webx.locale (public) and webx.panel-locale (admin) are distinct, driven by separate config lists. "Six public languages, two admin languages" is a supported configuration. Plan the panel translation budget as modules × languages of published lang files — a recurring content cost, not a one-time engineering task.
Fallback chaining. 'fallback' decides which language fills gaps via the service's chain(). Read it as a shipping posture: fallback on an incomplete language produces mixed-language pages users notice and report. The default becomes your policy unless you intervene.
Caching. Locale data is cached under CacheGroups::LOCALES. After any change to locales rows or published lang files, run the clear command:
php artisan webx:locales:clear
In development, set WEBX_LOCALIZATION_CACHE=false so you're not clearing after every edit.
Extensions you're allowed to make: publish and edit the config, publish and override lang tags per module (the package's ten validation languages are appended behind your app's lang/ strings, so app overrides win — confirm this ordering in a short spike, because it decides whether your copy or vendor copy ships), and adopt the model traits. That's the seam list. A missing seam is an upstream bug report, not a vendor patch.
Never delete a language to hide it. Translated text persists in every JSON map; deleting the locales row orphans the corpus. Set is_active = false instead. This is the single most expensive footgun in the package and it's an operating rule, not a technical constraint.
"Exactly one default" is enforced by the model, not the schema. Concurrent saves or raw SQL can leave you with two defaults or a denormalised code. Recovery means a cache clear after fixing rows. Don't touch is_default in SQL; go through the model.
No per-language unique index, and that's intentional. A JSON column cannot enforce unique translated slugs. If your product needs per-language address uniqueness, the storage model is wrong for you — not the package. Translated slugs are explicitly webx-ui/routing's scope and should be a separate workstream, or you will promise something this package cannot deliver.
Stale translations are the number-one day-one support ticket. lang edits don't invalidate automatically in a cached environment. When a string "doesn't change," run webx:locales:clear before you debug anything else.
Don't index or filter on translated columns in the DB. whereTranslation family exists for convenience; it will not scale to analytics or heavy sorting. Keep reporting on native columns.
The locales table wins over config after migration. Config is seed-only. A change to 'locales' in config after seeding does nothing until you re-seed — a classic "why isn't my new language showing up" moment.
Watch the panel() dictionary size. Widening 'namespaces' in config grows the generated panel dictionary. At small module counts it's invisible; ask upstream about a ceiling before you turn it on for twenty modules.
Vendor patches are unsupported. Editing vendor/webx-ui/localization works today and becomes an unpinnable fork tomorrow. Convert gaps into upstream issues and pin the three-to-four package cluster (localization, module-admin, routing, cache) together at upgrade time.
Version your expectations. Zero stars, zero dependents, read-only split of a monorepo, single vendor, no documented Laravel/PHP range in the source excerpt. Every upgrade is "wait for upstream, then re-pin." Frame this as a platform bet in any plan you write; it's the wrong risk read otherwise.
How can I help you explore Laravel packages today?