Search by

nvl / translations

nicolas_vls

Translation catalog scanning, editing, import, and export for Laravel

v2.2.1 2026-09-25 21:26 UTC

This package is auto-updated.

Last update: 2026-09-26 07:32:20 UTC


README

← NVL Laravel Suite

For support, open an issue. For vulnerabilities, use private reporting. See Contributing.

See the installation and publishing guide for Composer setup, configuration, migration ownership, and agent skills.

Quick reference

Item Value
Installed through composer require nvl/translations:^2.0
Module identifier nvl/translations
PHP namespace Nvl\Translations
Service provider Nvl\Translations\Providers\TranslationsServiceProvider
Configuration config/translations.php

The suite's Laravel 13 file-catalog module for reading, scanning, editing, synchronizing, and resaving PHP-array and JSON translation files.

Use nvl/translatable for locale-specific Eloquent content. This package only manages Laravel language files.

Purpose and boundary

The workflow is deliberately simple:

  1. Read translation files from configured source directories.
  2. Synchronize each leaf string into editable database rows.
  3. Edit those rows through package Actions or the optional management API.
  4. Resave the rows as valid Laravel PHP or JSON translation files.

The database is an editing and synchronization workspace. It is not installed as Laravel's runtime translation loader, does not override file lookup precedence, and does not store translatable model content.

Requirements and installation

  • PHP 8.4+
  • Laravel 13
  • nvl/core, nvl/filterable, and nvl/tenancy
composer require nvl/translations:^2.0
php artisan migrate
php artisan vendor:publish --tag=translations-config
php artisan vendor:publish --tag=translations-skills

Choose exactly one migration owner. For automatic vendor loading, leave translations.migrations.enabled=true and do not publish translations-migrations. For host-owned migrations, run php artisan vendor:publish --tag=translations-migrations, set translations.migrations.enabled=false before the first migration, and maintain the copied files as application migrations. Never run both sources; Laravel retimestamps published migrations.

The package supplies English and Bulgarian validation copy. Publish overrides when needed:

php artisan vendor:publish --tag=translations-translations

File formats

Both native Laravel formats are independent and may be synchronized together or separately:

lang/
├── en/
│   ├── messages.php
│   └── validation.php
├── bg/
│   └── messages.php
├── en.json
└── bg.json
  • PHP catalogs use locale directories and nested arrays.
  • JSON catalogs use one <locale>.json object per locale.
  • PHP rows retain their group path, such as messages or admin/actions.
  • JSON rows use Laravel's full source string as the key.
  • Only valid UTF-8 string or null leaf values are managed. Booleans and numbers are rejected instead of being silently coerced. Nested PHP arrays are flattened and reconstructed, including numeric keys at the top level or inside arrays; empty arrays contain no translation leaves.
  • Dots in PHP leaf identities represent nesting. Literal dots or empty strings inside an individual PHP array-key segment are rejected because they cannot be round-tripped without changing the array shape.

Configure source locations

The application source defaults to Laravel's lang_path(), which is the root lang directory in Laravel 13:

'paths' => [
    'app' => lang_path(),
    'vendor' => lang_path('vendor'),
],

'module_roots' => [],

'discovery' => [
    'modules' => false,
    'vendor' => false,
],

The conventions discovered automatically are:

  • app
  • module:<ModuleName> from Modules/<Module>/lang or Modules/<Module>/Resources/lang
  • vendor:<package> from lang/vendor/<package>

Module and vendor discovery are disabled by default. Mail files remain ordinary application PHP groups (mail or mail/*), not a second overlapping storage scope.

Add explicit module roots before enabling module discovery:

'module_roots' => [
    base_path('Modules'),
],

Additional configured file roots become custom:<name> scopes:

'custom_scopes' => [
    'shared' => base_path('translations/shared'),
    'frontend' => resource_path('locales'),
],

Every configured path must be an absolute local directory. Scope tokens and locale names are validated; HTTP and CLI input is never interpreted as an arbitrary output path.

Import files into the editable catalog

php artisan nvl:translations:sync
php artisan nvl:translations:sync --format=php
php artisan nvl:translations:sync --format=json
php artisan nvl:translations:sync --scope=app --scope=custom:shared --format=both

Programmatic use:

$result = app(ImportTranslationsAction::class)->execute(
    scopeTokens: ['app'],
    format: 'both',
);

The result reports scopes, files, entries, created rows, updated rows, preserved database edits, conflicts, newly missing rows, and warnings.

Import is read-first and mutation-second. With strict mode enabled, a malformed source file stops synchronization before any selected scope changes in the database:

'import' => [
    'fail_on_error' => true,
    'conflict_strategy' => 'fail',
],

Conflict strategies:

  • fail rolls back the selected synchronization and throws a conflict exception, producing a non-zero command exit code.
  • prefer_file uses the current file value.
  • prefer_database preserves the editable workspace value.
  • --strategy=interactive prompts for one of those strategies in an interactive terminal.

Missing configured directories are reported and skipped. A successfully read selected format marks rows absent from that source as missing; parse errors never cause missing markers.

PHP catalog loading is output-buffered: a catalog that emits output is rejected instead of corrupting a console or API response. Files or directories reached through symbolic links are rejected.

Edit database rows

Use the validated DTO and Action:

$entry = app(UpdateTranslationEntryAction::class)->execute(
    entry: $entry,
    data: UpdateTranslationEntryPayload::validateAndCreate([
        'value' => 'Save changes',
        'expectedRevision' => $entry->revision,
    ]),
);

ListTranslationEntriesAction provides paginated, filterable administrative reads. ListTranslationFilterOptionsAction returns available scope, locale, and PHP-group filters.

Edits use both a database row lock and the same workspace lock as synchronization, and require the current optimistic revision. The source hash is intentionally not replaced by a database edit. A later import can therefore distinguish an unsaved database edit from an unchanged source file.

Catalog statistics and shared filters

Use the public schema service when an application owns its management controller. This avoids constructing TranslationEntry only to discover the package's filter allowlist and keeps list and statistics inputs identical:

use Nvl\Filterable\Http\QueryFilterSetFactory;
use Nvl\Translations\Actions\Entries\GetTranslationCatalogStatisticsAction;
use Nvl\Translations\Services\TranslationEntryFilterSchema;

$schema = app(TranslationEntryFilterSchema::class)->make();
$filters = app(QueryFilterSetFactory::class)->fromQuery(
    request()->query(),
    $schema,
);

$statistics = app(GetTranslationCatalogStatisticsAction::class)->execute($filters);

GetTranslationCatalogStatisticsAction::execute(?FilterSet $filters = null) authorizes TranslationsAbility::ListEntries before its first query and returns TranslationCatalogStatisticsData. The projection contains total, missing, conflicts, changed, locales, and scopes:

  • missing counts rows whose durable is_missing marker is true.
  • conflicts counts rows whose synchronization status is conflict.
  • changed counts rows with a source hash whose durable status is edited or conflict. A preserved database edit remains edited; “preserved” is an import result counter, not a stored synchronization status.
  • locales and scopes are array<string, int> maps sorted by count descending and key ascending, capped at 100 entries each. Scope keys use command-compatible tokens such as app, module:Website, and custom:shared. JSON serialization always emits both maps as objects, including valid numeric-only locale keys.

The statistics read executes one scalar aggregate and one grouped query per dimension: three queries regardless of catalog size. Caller-provided sort clauses do not affect aggregates; every allowlisted filter is applied with the same semantics as ListTranslationEntriesAction.

Configure output directories

source writes back to each scope's configured source directory. Additional output locations are named, trusted maps:

'export_targets' => [
    'source' => [],

    'generated' => [
        'app' => storage_path('translations/generated/app'),
        'custom:shared' => storage_path('translations/generated/shared'),
    ],
],

A named target must explicitly map every selected scope. This supports generated directories, deployment staging, frontend handoff directories, or other application-owned locations without accepting filesystem paths from requests.

Named destinations must be distinct from every source scope, the backup directory, and each other. Duplicate discovered scope tokens that point at different roots fail configuration validation.

Resave files

php artisan nvl:translations:export --dry-run
php artisan nvl:translations:export --scope=app --format=php --force
php artisan nvl:translations:export --scope=app --format=json --locales=en,bg --force
php artisan nvl:translations:export --scope=app --target=generated --format=both --force

Programmatic use:

$result = app(ExportTranslationsAction::class)->execute(
    scopeTokens: ['app'],
    locales: ['en', 'bg'],
    format: 'both',
    target: 'generated',
    prune: false,
);

Exports:

  • rebuild nested PHP arrays from dot keys;
  • sort groups and keys deterministically;
  • emit strict PHP files and pretty UTF-8 JSON;
  • create missing target directories;
  • stage and validate every replacement before changing any target file;
  • apply sibling-file atomic replacements as one backed-up batch and restore original contents if a later operation fails;
  • preserve existing file permissions when possible;
  • never write outside the configured scope target.

Every export performs a fresh authoritative-source read first. Export stops when that read is incomplete, including when best-effort importing is enabled, so an unreadable source file cannot be overwritten from a partial workspace.

Exporting to source updates the synchronization hash because the imported source was replaced. Exporting to another named target does not alter source tracking, so a later source import still preserves unexported database edits.

Pruning

Pruning is opt-in because it deletes stale translation files:

php artisan nvl:translations:prune \
    --scope=app \
    --locales=en,bg \
    --format=both \
    --target=generated \
    --dry-run

Pruning is constrained to the selected configured target, scopes, locales, and formats:

  • PHP pruning only considers .php files below the selected locale directory.
  • JSON pruning only considers the selected <locale>.json file.
  • Other extensions and unselected locales are untouched.

Source usage scanning

Configure the application source paths and extensions scanned for literal Laravel translation keys:

'scan' => [
    'paths' => [
        base_path('app'),
        base_path('extensions'),
        resource_path('views'),
        resource_path('js'),
    ],
    'extensions' => ['php', 'blade.php', 'js', 'jsx', 'ts', 'tsx', 'vue'],
    'retention_days' => 30,
    'namespaces' => [
        'content' => 'module:Content',
        'package-ui' => 'vendor:package-ui',
    ],
],
php artisan nvl:translations:scan
php artisan nvl:translations:unused --help

Dynamic keys cannot be discovered statically. Preserve them in unused reports with translations.scan_allowlist.

The scanner is intentionally heuristic: it records only configured literal-key call patterns. The package defaults cover Laravel helpers, Lang::get, Lang::choice, Blade @lang/@choice, and JavaScript t/$t; override scan.patterns only with tested regular expressions. Non-namespaced usages belong to the app scope; an unknown namespace is skipped rather than treated as a global usage. Successful scans prune usage history older than scan.retention_days when retention is greater than zero.

Each successful scan stores a durable run marker, including zero-hit scans. Latest-scan unused reports therefore do not reuse stale hits, and usage matching keeps PHP and JSON identities separate. Ambiguous namespace names must be resolved explicitly through scan.namespaces.

Workspace synchronization uses an atomic cache lock:

'lock' => [
    'store' => 'redis', // null uses the application default
    'seconds' => 300,
    'wait_seconds' => 0,
],

All application nodes must use the same lock-capable cache store. Size the lock lifetime above the longest expected import, export, or scan.

Optional management API

The API defaults to api/v1/translations:

'routes' => [
    'enabled' => true,
    'prefix' => 'api/v1',
    'middleware' => ['api'],
    'management_middleware' => ['auth'],
],

It provides list, row update, import, export, and scan endpoints. Entry updates require both value and expectedRevision. Import/export requests accept bounded configured scope-token lists, php|json|both, locale filters, a named export target, and the explicit prune flag. Every non-dry-run API export requires force=true; pruning additionally authorizes the independent prune ability. Stale revisions and source conflicts return 409, workspace locks return 423, and unsafe public inputs return 422. Applications may add Sanctum, verification, permissions, throttling, or response middleware.

Disable package routes when the consumer owns its management controllers:

'routes' => [
    'enabled' => false,
],

Operational workflow

The safe database-editing cycle is:

php artisan nvl:translations:sync --scope=app --format=both
# Edit rows through the management application.
php artisan nvl:translations:export --scope=app --target=source --format=both --force
git diff -- lang
php artisan nvl:translations:sync --scope=app --format=both

Review file diffs before committing. Use --prune only when the database catalog is intentionally authoritative for the selected destination.

Inspect installation and workspace status without mutation:

php artisan nvl:translations:status --format=json
php artisan nvl:translations:doctor --strict --format=json

TypeScript, skill, and quality

Tenant copy overrides

Source scans, imports, updates, and exports are platform-only. Tenant copy uses the separate allowlisted override repository and exports private artifacts to tenants/<tenant>/translations/<artifact>.json; it never rewrites source files or mutates Laravel's global translator.

DTOs and enums register with Core's Data provider; configured type generation includes them automatically. Publishing translations-skills installs package-specific agent guidance.

From a standalone checkout of the public Translations repository:

composer install
composer quality

composer quality checks Pint formatting, Larastan, and the isolated Testbench/Pest suite.

License

Released under the MIT License.