syriable / laravel-localizer
A modern, focused localization extraction engine for Laravel 13. Discovers, extracts, and normalizes translatable strings from Blade, PHP, Vue, JavaScript, TypeScript, Livewire, and Inertia source files.
Requires
- php: ^8.4
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/events: ^13.0
- illuminate/filesystem: ^13.0
- illuminate/pipeline: ^13.0
- illuminate/support: ^13.0
- symfony/finder: ^7.2
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.4.1
- pestphp/pest-plugin-laravel: ^4.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
This package is auto-updated.
Last update: 2026-05-23 14:30:39 UTC
README
Syriable Localizer is a focused, modern extraction engine for Laravel 13. It discovers translatable strings in your Blade, PHP, Vue, JavaScript, TypeScript, Livewire and Inertia source files, normalizes them, and hands them back as typed immutable DTOs through a stable, contracts-driven API.
It is deliberately scoped to a single responsibility: extraction. It does not write language files, manage translators, or sync with third parties — those concerns belong to ecosystem packages built on top of this engine.
use Syriable\Localizer\Facades\Localizer; $result = Localizer::scan(); foreach ($result->unique() as $string) { echo "[{$string->kind->value}] {$string->value}\n"; echo " at {$string->location->path}:{$string->location->line}\n"; }
Where this fits
This package is the extraction layer: it gives you a clean, typed list of every translatable string in your codebase, with source locations and stable fingerprints. What you do with that list is up to you.
Common consumers of a ScanResult:
- Generate language files. For each short key,
$string->langFilePath($locale)returns the full on-disk path (e.g.lang/en/profile/buttons.phporlang/vendor/syriable/en/profile/buttons.php);$string->keyis the dotted path inside the file. For JSON keys, the same method returnslang/{locale}.json. - Detect missing translations. Diff the unique strings against your existing language files to find untranslated keys.
- Sync with translation vendors. Push the unique strings to Lokalise, Crowdin, POEditor, or a custom translation backend; pull back the translations.
- Audit translation coverage in CI. Fail the build if scanned strings outnumber translated ones by more than X%.
- Source-locate a key. Given a key string, jump to
$location->path:$location->lineto find where it's used.
None of these are built into this package — they are downstream concerns that build on top of the engine. A companion writer/sync package is on the roadmap; in the meantime, the three events plus the Localizer::normalize() extension point give you everything needed to build your own.
Support us
If this package helps you ship better Laravel applications, please consider starring the repository and following @syriable for updates on future ecosystem releases.
We highly appreciate hearing about how you're using it — open a Discussion to share, request a feature, or propose an extractor for a new source language.
Installation
Install via Composer:
composer require syriable/laravel-localizer --dev
Requirements: PHP 8.4+, Laravel 13.x.
The service provider is auto-discovered. Publish the config file with:
php artisan vendor:publish --tag="localizer-config"
This is the contents of the published config file:
return [ 'paths' => [ resource_path('views'), resource_path('js'), app_path(), ], 'exclude' => [ '**/node_modules/**', '**/vendor/**', '**/storage/**', '**/bootstrap/cache/**', '**/.git/**', '**/tests/**', ], 'extractors' => [ 'blade' => \Syriable\Localizer\Extractors\BladeExtractor::class, 'livewire' => \Syriable\Localizer\Extractors\LivewireExtractor::class, 'inertia' => \Syriable\Localizer\Extractors\InertiaExtractor::class, 'php' => \Syriable\Localizer\Extractors\PhpExtractor::class, 'vue' => \Syriable\Localizer\Extractors\VueExtractor::class, 'typescript' => \Syriable\Localizer\Extractors\TypeScriptExtractor::class, 'javascript' => \Syriable\Localizer\Extractors\JavaScriptExtractor::class, ], 'cache' => [ 'enabled' => true, 'path' => storage_path('app/.localizer/cache.json'), ], 'lock' => [ 'name' => 'localizer:scan', 'seconds' => 60, ], ];
Extractor registration order matters. When two extractors match the same file (e.g. *.blade.php matches both Blade and PHP), the first registered wins. The default order is correct for typical Laravel applications.
Usage
A simple scan
use Syriable\Localizer\Facades\Localizer; $result = Localizer::scan();
An explicit request
use Syriable\Localizer\Data\ScanRequest; $result = Localizer::scan(new ScanRequest( paths: [resource_path('views'), resource_path('js')], exclude: ['**/node_modules/**', '**/legacy/**'], extractors: ['blade', 'vue'], useCache: true, ));
The fluent builder
$result = Localizer::in([resource_path('views'), resource_path('js')]) ->exclude('**/legacy/**') ->only(['blade', 'vue']) ->fresh() ->scan();
The builder is immutable — every method returns a new instance, so configurations can be safely shared and reused.
Working with the result
$result->strings; // list<ExtractedString>, in discovery order $result->count(); // total strings including duplicates $result->unique(); // list<ExtractedString> deduplicated by fingerprint $result->groupedByKind(); // ['short_key' => [...], 'json_key' => [...]] $result->groupedByFile(); // ['pagination' => [...], 'buttons' => [...]] $result->filesScanned; // total files (cached + fresh) $result->filesFromCache; // files served from cache $result->filesFresh(); // files extracted this run $result->durationMs; // float $result->toArray(); // serializable representation
The ExtractedString DTO
For ShortKeys, the value is decomposed into the filesystem target plus the key inside the target file:
$string->value; // 'Welcome back', 'pagination.next', or 'syriable::profile/buttons.submit.label' $string->kind; // StringKind::JsonKey | StringKind::ShortKey $string->extractor; // 'blade' // ShortKey decomposition (all null/empty for JsonKey): $string->package; // 'syriable' (vendor namespace before `::`), or null $string->directories; // ['profile'] or ['profile', 'button', 'form'] — directory chain $string->file; // 'buttons' (the PHP file, without `.php`) $string->key; // 'submit.label' (the dotted path inside the file) $string->location; // SourceLocation { path, line, column } $string->filePath(); // 'profile/buttons.php' — relative file path, or null for JsonKey $string->langFilePath('en'); // 'lang/en/profile/buttons.php' or 'lang/vendor/syriable/en/...' — full on-disk path $string->fingerprint(); // xxh128 content hash for deduplication $string->toArray();
All DTOs are PHP 8.4 readonly — immutable by language guarantee, not by convention.
Short keys vs JSON keys
Every extracted string is classified by how it maps onto Laravel's translation file layout, not by where it was extracted from. The classification determines which file the translation lives in and how the key resolves inside it.
ShortKey — a value that points at a PHP file and a key inside it. The general grammar:
[package::]dir1/dir2/.../file.key.subkey
with package, every dir, file, and every key segment being lowercase alphanumeric with _ or -. Examples:
| Value | package | directories | file | key | Destination file |
|---|---|---|---|---|---|
pagination.next |
null |
[] |
pagination |
next |
lang/{locale}/pagination.php |
auth.failed.attempts |
null |
[] |
auth |
failed.attempts |
lang/{locale}/auth.php |
profile/buttons.submit.label |
null |
['profile'] |
buttons |
submit.label |
lang/{locale}/profile/buttons.php |
profile/button/form/icon.submit.label |
null |
['profile', 'button', 'form'] |
icon |
submit.label |
lang/{locale}/profile/button/form/icon.php |
syriable::buttons.submit |
syriable |
[] |
buttons |
submit |
lang/vendor/syriable/{locale}/buttons.php |
syriable::profile/buttons.submit.label |
syriable |
['profile'] |
buttons |
submit.label |
lang/vendor/syriable/{locale}/profile/buttons.php |
The key path resolves inside the file's returned array via data_get($array, $key). For submit.label against ['submit' => ['label' => 'Save']], the value is 'Save'.
A string is classified as ShortKey only when it strictly matches the grammar above. Any whitespace, mixed case, punctuation, or malformed structure (/buttons.submit, profile//x.y, ::buttons.x) falls through to JsonKey — in which case the entire string IS the key and the translation lives in lang/{locale}.json:
| Value | Destination file |
|---|---|
Welcome back |
lang/{locale}.json |
You have :count messages |
lang/{locale}.json |
Click here to continue |
lang/{locale}.json |
The name "JsonKey" refers to Laravel's terminology for translations whose keys are stored in a JSON file — it has nothing to do with whether the string was extracted from a .json source file (the package doesn't scan those). All JsonKeys go into the single lang/{locale}.json file.
The Artisan command
A single command. Every option is explicit and predictable.
# Use config defaults php artisan localizer:scan # Specific paths php artisan localizer:scan resources/views resources/js # Ignore the cache php artisan localizer:scan --fresh # Limit to specific extractors php artisan localizer:scan --extractor=blade --extractor=vue # JSON output (for piping into other tools) php artisan localizer:scan --json # Summary only php artisan localizer:scan --summary
The command always exits with 0 on a successful scan — an empty result is not an error.
Caching and incremental scans
Every scan computes an xxh128 content fingerprint for each discovered file. The cache (a single JSON document at storage/app/.localizer/cache.json) maps each file path to its last-known fingerprint and the strings extracted at that time. On the next scan:
- File fingerprint matches → strings loaded from cache (no re-read, no re-extraction).
- File fingerprint differs → file re-extracted, cache updated.
A no-change scan over thousands of files completes in tens of milliseconds. The cache is written atomically (rename() over a temporary file) and protected by Laravel's lock provider so parallel CI runs do not corrupt each other.
Cache versioning. The cache file embeds a schema version. When you upgrade between major versions of this package, the format may change incompatibly and the old cache will be rejected. If you see a LocalizerException mentioning a cache-version mismatch after upgrading, run the scan once with --fresh to rebuild it:
php artisan localizer:scan --fresh
This is a one-time cost per major upgrade. Minor and patch versions preserve cache compatibility.
Extending the engine
The engine has three extension points. All of them follow standard Laravel conventions — no engine subclassing required.
1. Custom extractors. Implement Syriable\Localizer\Contracts\Extractor and register the class in config('localizer.extractors'):
use Syriable\Localizer\Contracts\Extractor; use Syriable\Localizer\Data\DiscoveredFile; final class TwigExtractor implements Extractor { public function name(): string { return 'twig'; } public function patterns(): array { return ['*.twig']; } public function extract(DiscoveredFile $file, string $contents): iterable { // yield ExtractedString instances... } }
2. Normalizers. Transform or drop strings before they reach the result. A normalizer is any callable or Normalizer implementation that returns one of three values:
- The original
ExtractedString→ the string passes through unchanged. - A new
ExtractedString→ the original is replaced. Use this to rewrite values, change kinds, attach groups, etc. null→ the string is dropped from the result entirely.
Normalizers run in registration order and apply to every scan. They must be pure functions of their input — no I/O, no shared mutable state.
// Drop strings: filter out debug keys. Localizer::normalize(fn ($string) => str_starts_with($string->value, 'debug.') ? null : $string); // Transform strings: trim whitespace from values. Localizer::normalize(fn ($string) => $string->withValue(trim($string->value))); // Filter + transform in one normalizer: drop empties, lowercase the rest. Localizer::normalize(function ($string) { $trimmed = trim($string->value); return $trimmed === '' ? null : $string->withValue(strtolower($trimmed)); });
Clear all registered normalizers with Localizer::withoutNormalizers() — useful in tests or when temporarily disabling a downstream package's normalizers.
3. Events. For everything else — telemetry, progress bars, downstream packages — listen to one of the three events:
| Event | Fired | Payload |
|---|---|---|
ScanStarted |
Before discovery | ScanRequest |
FileExtracted |
Once per file, including cache hits | DiscoveredFile, list<ExtractedString>, bool $fromCache |
ScanCompleted |
After normalization, before return | ScanResult |
use Syriable\Localizer\Events\ScanCompleted; use Illuminate\Support\Facades\Event; Event::listen(ScanCompleted::class, function (ScanCompleted $event) { info("Scan complete: {$event->result->count()} strings"); });
Analyzing translation calls
Beyond simple key extraction, the package can perform a deeper inspection of every translation call site — capturing not just the key, but the replacements array and the kind of PHP expression behind each placeholder.
php artisan localizer:analyze # configured paths php artisan localizer:analyze app/ resources/views # explicit paths php artisan localizer:analyze --json # structured output php artisan localizer:analyze --key=auth.failed # filter by key
For every __(), trans(), @lang(), trans_choice(), Lang::get()
or Lang::choice() call the analyzer extracts the literal key plus
every :placeholder => $expression pair, then classifies each
expression into one of eight typed categories: variable,
object_property, nested_object_property, function_call,
method_call, static_method_call, literal, expression.
Example: for __('welcome', ['name' => $user->profile->full_name, 'count' => getOrdersCount($user->id)]) the JSON output contains:
{
"key": "welcome",
"function": "__",
"placeholders": [
{
"placeholder": ":name",
"source": "$user->profile->full_name",
"type": "nested_object_property",
"structure": { "object": "$user", "path": ["profile", "full_name"] }
},
{
"placeholder": ":count",
"source": "getOrdersCount($user->id)",
"type": "function_call",
"structure": { "function": "getOrdersCount", "arguments": ["$user->id"] }
}
],
"lang_example": { "welcome": "Welcome :name :count" }
}
This is the engine behind the AI generation strategy's prompt-enrichment step, and it is exposed standalone so downstream tooling (linters, audits, IDE plugins) can reason about placeholder shapes.
Generating translation files
The package ships a companion localizer:generate command that turns
a scan result into actual PHP translation files. It's an optional
convenience built on top of the extraction engine — the engine itself
remains write-free.
php artisan localizer:generate # uses app.locale php artisan localizer:generate --locale=fr php artisan localizer:generate --all-locales # from localizer.generator.locales php artisan localizer:generate --dry-run # preview without writing php artisan localizer:generate --strategy=key # humanized | key | empty | ai php artisan localizer:generate --namespace=acme php artisan localizer:generate --force # overwrite existing values php artisan localizer:generate --fresh # ignore scan cache php artisan localizer:generate --no-analyze # skip call-site analysis
Before generating, the command automatically runs the call-site analyzer
over your source paths and passes the resulting placeholder context to
any strategy that implements AnalysisAwareStrategy (most notably the
ai strategy). Use --no-analyze to disable this step.
For every scanned ShortKey, the generator writes the missing keys to
the corresponding PHP file under lang/{locale}/ (or
lang/vendor/{package}/{locale}/ for vendor-namespaced keys), preserving
the directory structure encoded in the key. JsonKey strings are written
to lang/{locale}.json. A key like profile/btn/form.submit.label
produces:
// lang/en/profile/btn/form.php <?php declare(strict_types=1); return [ 'submit' => [ 'label' => 'Label', ], ];
Safety guarantees:
- Existing translation values are never overwritten unless you pass
--force. Re-running the command on a populatedlang/directory only adds the missing keys. - All writes are atomic (rename-over-temp via
AtomicWriter). --dry-runnever touches the filesystem; it prints what would be written.- Locales are validated against
[A-Za-z0-9_-]+to prevent path-traversal injection through crafted locale strings.
Value strategies:
| Strategy | Example key | Generated value |
|---|---|---|
humanized (default) |
submit_btn |
Submit btn |
key |
auth.login.failed |
auth.login.failed |
empty |
any | '' |
ai |
auth.login.failed |
Connexion échouée (real translation via API) |
Register a custom strategy from a service provider:
use Syriable\Localizer\Generator\StrategyRegistry; $this->app->extend(StrategyRegistry::class, function (StrategyRegistry $registry) { $registry->register(new MyCustomStrategy); return $registry; });
Custom strategies may also implement
Syriable\Localizer\Contracts\LocaleAwareStrategy (to receive the
target locale before generation begins) or
Syriable\Localizer\Contracts\AnalysisAwareStrategy (to receive the
key → TranslationCallAnalysis map produced by the analyzer).
Configuring default locales:
// config/localizer.php 'generator' => [ 'locales' => ['en', 'fr', 'de'], // for --all-locales 'strategy' => 'humanized', // default --strategy ],
AI translation strategy
The ai strategy translates missing keys via the Anthropic Messages
API, with persistent caching and placeholder preservation. It is
opt-in and never invoked unless you pass --strategy=ai (or set it as
the default).
export ANTHROPIC_API_KEY="sk-ant-..." php artisan localizer:generate --locale=fr --strategy=ai php artisan localizer:generate --locale=de --strategy=ai --dry-run
For each missing key the strategy:
- Masks any
:placeholdertokens (e.g.:name,:count) to opaque{{P0}},{{P1}}tokens so the model cannot accidentally translate them. - Checks the persistent cache (keyed by model + source locale + target locale + masked text). Cache hits skip the API call.
- Calls the Anthropic API with a prompt enriched by call-site analysis — the AI sees which placeholder is a person's name, which is a count, etc., and can produce grammatically appropriate translations.
- Restores the original
:placeholdertokens in the translated result. - Falls back to the configured fallback strategy (defaults to
humanized) on any API or network failure — generation never crashes mid-run.
The cache lives at storage/app/.localizer/ai-cache.json by default
and is deliberately kept outside storage/framework/cache/ so that
php artisan cache:clear never evicts expensive AI translations.
// config/localizer.php 'ai' => [ 'model' => env('LOCALIZER_AI_MODEL', 'claude-opus-4-7'), 'api_key' => env('ANTHROPIC_API_KEY', ''), 'source_locale' => env('LOCALIZER_AI_SOURCE_LOCALE', 'en'), 'fallback_strategy' => 'humanized', 'cache_path' => storage_path('app/.localizer/ai-cache.json'), ],
Changing the model, source locale, or target locale automatically invalidates the cache for affected entries — the key is a content hash of all four parameters.
Testing
composer test
This runs the full Pest suite. Other quality gates:
composer test-coverage # Pest with coverage, min 95% composer analyse # Larastan at level 8 composer format # Apply Pint composer format-check # Verify formatting without changing files composer check # format-check + analyse + test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.