├── .gitignore ├── .gitattributes ├── config └── fluent.php ├── LICENSE.md ├── composer.json ├── src ├── FluentServiceProvider.php └── FluentTranslator.php └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /.cache 2 | /vendor 3 | /phpunit.xml 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github export-ignore 2 | /tests export-ignore 3 | /.editorconfig export-ignore 4 | /.php-cs-fixer.php export-ignore 5 | /composer.lock export-ignore 6 | /phpstan.neon export-ignore 7 | /phpunit.dist.xml export-ignore 8 | -------------------------------------------------------------------------------- /config/fluent.php: -------------------------------------------------------------------------------- 1 | env('APP_ENV', 'production') !== 'production', 12 | 13 | /* 14 | * Determines if it should use Unicode isolation marks (FSI, PDI) 15 | * for bidirectional interpolations. You may want to enable this 16 | * behaviour if your application uses right-to-left script. 17 | */ 18 | 'use_isolating' => false, 19 | 20 | ]; 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2023 Jeremiah Major 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jrmajor/laravel-fluent", 3 | "description": "Fluent translations for Laravel", 4 | "license": "MIT", 5 | "keywords": [ 6 | "translator", 7 | "translation", 8 | "i18n", 9 | "internationalization", 10 | "l10n", 11 | "localization", 12 | "locale", 13 | "language", 14 | "plural", 15 | "gender", 16 | "ftl", 17 | "mozilla" 18 | ], 19 | "authors": [ 20 | { 21 | "name": "Jeremiasz Major", 22 | "email": "jrh.mjr@gmail.com" 23 | } 24 | ], 25 | "homepage": "https://github.com/jrmajor/laravel-fluent", 26 | "require": { 27 | "php": "8.3 - 8.4", 28 | "jrmajor/fluent": "^1.0", 29 | "laravel/framework": "^11.0 || ^12.0" 30 | }, 31 | "require-dev": { 32 | "jrmajor/cs": "^0.6.1", 33 | "orchestra/testbench": "^9.1 || ^10.1", 34 | "phpstan/phpstan": "^2.0", 35 | "phpunit/phpunit": "^11.4" 36 | }, 37 | "minimum-stability": "dev", 38 | "prefer-stable": true, 39 | "autoload": { 40 | "psr-4": { 41 | "Major\\Fluent\\Laravel\\": "src" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "Major\\Fluent\\Laravel\\Tests\\": "tests" 47 | } 48 | }, 49 | "extra": { 50 | "laravel": { 51 | "providers": [ 52 | "Major\\Fluent\\Laravel\\FluentServiceProvider" 53 | ] 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/FluentServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 26 | __DIR__ . '/../config/fluent.php' => config_path('fluent.php'), 27 | ], 'fluent-config'); 28 | } 29 | 30 | public function register(): void 31 | { 32 | $this->mergeConfigFrom(__DIR__ . '/../config/fluent.php', 'fluent'); 33 | 34 | // We need to force register the Laravel translator provider, so that 35 | // we can obtain an instance of BaseTranslator. Normally service providers 36 | // under Illuminate\ namespace are loaded first, but this one is deferred. 37 | if (! $this->app->providerIsLoaded(TranslationServiceProvider::class)) { 38 | $this->app->registerDeferredProvider(TranslationServiceProvider::class); 39 | } 40 | 41 | // BaseTranslator is an alias to 'translator'. Setting an instance is the only way 42 | // to remove an alias, so if we don't do this before overwriting 'translator', 43 | // there will be no way to resolve BaseTranslator from the container. 44 | $this->app->instance(BaseTranslator::class, $this->app[BaseTranslator::class]); 45 | 46 | $this->app->singleton('translator', function (Application $app) { 47 | /** @phpstan-ignore offsetAccess.nonOffsetAccessible */ 48 | $options = $app['config']['fluent']; 49 | 50 | assert( 51 | is_array($options) 52 | && is_bool($options['strict']) 53 | && is_bool($options['use_isolating']), 54 | ); 55 | 56 | return new FluentTranslator( 57 | /** @phpstan-ignore argument.type */ 58 | baseTranslator: $app[BaseTranslator::class], 59 | /** @phpstan-ignore argument.type */ 60 | files: $app[Filesystem::class], 61 | /** @phpstan-ignore argument.type */ 62 | path: $app['path.lang'], 63 | locale: $app->getLocale(), 64 | fallback: $app->getFallbackLocale(), 65 | bundleOptions: [ 66 | 'strict' => $options['strict'], 67 | 'useIsolating' => $options['use_isolating'], 68 | ], 69 | ); 70 | }); 71 | 72 | $this->app->alias('translator', FluentTranslator::class); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jrmajor/laravel-fluent 2 | 3 | Latest Stable Version 4 | Required PHP Version 5 | 6 | Unleash the expressive power of the natural language in your Laravel application with [Project Fluent](https://projectfluent.org), a localization system designed by Mozilla. 7 | 8 | Read the [Fluent Syntax Guide](https://projectfluent.org/fluent/guide/) or try it out in the [Fluent Playground](https://projectfluent.org/play/) to learn more about the syntax. 9 | 10 | ```ftl 11 | shared-photos = 12 | { $userName } { $photoCount -> 13 | [one] added a new photo 14 | *[other] added { $photoCount } new photos 15 | } to { $userGender -> 16 | [male] his stream 17 | [female] her stream 18 | *[other] their stream 19 | }. 20 | ``` 21 | 22 | ```php 23 | __('stream.shared-photos', [ 24 | 'userName' => 'jrmajor', 25 | 'photoCount' => 2, 26 | 'userGender' => 'male', 27 | ]); // jrmajor added 2 new photos to his stream. 28 | ``` 29 | 30 | This package is a Laravel wrapper around [jrmajor/fluent-php](https://github.com/jrmajor/fluent-php). 31 | 32 | You may install it via Composer: `composer require jrmajor/laravel-fluent`. The package will automatically register itself. 33 | 34 | ## Usage 35 | 36 | This package replaces default Laravel translator with `Major\Fluent\Laravel\FluentTranslator`. 37 | 38 | ```php 39 | app('translator') instanceof Major\Fluent\Laravel\FluentTranslator; // true 40 | ``` 41 | 42 | Fluent translations are stored in `.ftl` files. Place them among your `.php` translation files in your Laravel app: 43 | 44 | ``` 45 | /resources 46 | /lang 47 | /en 48 | menu.ftl 49 | validation.php 50 | /pl 51 | menu.ftl 52 | validation.php 53 | ``` 54 | 55 | If there is no Fluent message for a given key, translator will fall back to a `.php` file, which allows you to introduce Fluent translation format progressively. 56 | 57 | Laravel validator uses custom logic for replacing the `:attribute` variable and requires deeply nested keys, which are not supported in Fluent, so you should leave `validation.php` file in the default Laravel format. 58 | 59 | The `trans_choice()` helper always falls back to the default translator, as the Fluent format eliminates the need for this function. 60 | 61 | You may use the `FluentTranslator::addFunction()` method to register [Fluent functions](https://projectfluent.org/fluent/guide/functions.html). 62 | 63 | ### Configuration 64 | 65 | Optionally, you can publish the configuration file with this command: 66 | 67 | ```php 68 | php artisan vendor:publish --tag fluent-config 69 | ``` 70 | 71 | This will publish the following file in `config/fluent.php`: 72 | 73 | ```php 74 | return [ 75 | 76 | /* 77 | * In strict mode, exceptions will be thrown for syntax errors 78 | * in .ftl files, unknown variables in messages etc. 79 | * It's recommended to enable this setting in development 80 | * to make it easy to spot mistakes. 81 | */ 82 | 'strict' => env('APP_ENV', 'production') !== 'production', 83 | 84 | /* 85 | * Determines if it should use Unicode isolation marks (FSI, PDI) 86 | * for bidirectional interpolations. You may want to enable this 87 | * behaviour if your application uses right-to-left script. 88 | */ 89 | 'use_isolating' => false, 90 | 91 | ]; 92 | ``` 93 | 94 | ## Testing 95 | 96 | ```sh 97 | vendor/bin/phpunit # Tests 98 | vendor/bin/phpstan analyze # Static analysis 99 | vendor/bin/php-cs-fixer fix # Formatting 100 | ``` 101 | -------------------------------------------------------------------------------- /src/FluentTranslator.php: -------------------------------------------------------------------------------- 1 | > */ 18 | private array $loaded = []; 19 | 20 | /** @var array */ 21 | private array $functions = []; 22 | 23 | public function __construct( 24 | protected BaseTranslator $baseTranslator, 25 | protected Filesystem $files, 26 | protected string $path, 27 | protected string $locale, 28 | protected string $fallback, 29 | /** @var array{strict: bool, useIsolating: bool} */ 30 | protected array $bundleOptions, 31 | ) { } 32 | 33 | public function hasForLocale(string $key, ?string $locale = null): bool 34 | { 35 | return $this->has($key, $locale, false); 36 | } 37 | 38 | public function has(string $key, ?string $locale = null, bool $fallback = true): bool 39 | { 40 | return $this->get($key, [], $locale, $fallback) !== $key; 41 | } 42 | 43 | /** 44 | * @param string $key 45 | * @param array $replace 46 | * @param ?string $locale 47 | * 48 | * @return string|array 49 | */ 50 | public function get($key, array $replace = [], $locale = null, bool $fallback = true): string|array 51 | { 52 | $locale ??= $this->locale; 53 | 54 | $segments = explode('.', $key, limit: 2); 55 | 56 | if (str_contains($key, '::') || count($segments) !== 2) { 57 | /** @phpstan-ignore return.type */ 58 | return $this->baseTranslator->get($key, $replace, $locale, $fallback); 59 | } 60 | 61 | [$group, $item] = $segments; 62 | 63 | $message = $this->getBundle($locale, $group)?->message($item, $replace); 64 | 65 | if ($fallback && $this->fallback !== $locale) { 66 | $message ??= $this->getBundle($this->fallback, $group)?->message($item, $replace); 67 | } 68 | 69 | /** @phpstan-ignore return.type */ 70 | return $message ?? $this->baseTranslator->get($key, $replace, $locale, $fallback); 71 | } 72 | 73 | public function addFunction(string $name, Closure $function): void 74 | { 75 | if (array_key_exists($name, $this->functions)) { 76 | throw new FunctionExistsException($name); 77 | } 78 | 79 | $this->functions[$name] = $function; 80 | 81 | foreach ($this->loaded as $locale) { 82 | foreach ($locale as $bundle) { 83 | if ($bundle !== false) { 84 | $bundle->addFunction($name, $function); 85 | } 86 | } 87 | } 88 | } 89 | 90 | private function getBundle(string $locale, string $group): ?FluentBundle 91 | { 92 | if (! isset($this->loaded[$locale][$group])) { 93 | $this->loaded[$locale][$group] = $this->loadFtl($locale, $group) ?? false; 94 | } 95 | 96 | return $this->loaded[$locale][$group] ?: null; 97 | } 98 | 99 | private function loadFtl(string $locale, string $group): ?FluentBundle 100 | { 101 | $path = "{$this->path}/{$locale}/{$group}.ftl"; 102 | 103 | if (! $this->files->exists($path)) { 104 | return null; 105 | } 106 | 107 | return (new FluentBundle($locale, ...$this->bundleOptions)) 108 | ->addFtl($this->files->get($path)) 109 | ->addFunctions($this->functions); 110 | } 111 | 112 | /** 113 | * @param string $key 114 | * @param Countable|int|array $number 115 | * @param array $replace 116 | * @param ?string $locale 117 | */ 118 | public function choice($key, $number, array $replace = [], $locale = null): string 119 | { 120 | return $this->baseTranslator->choice($key, $number, $replace, $locale); 121 | } 122 | 123 | /** 124 | * @param array $lines 125 | */ 126 | public function addLines(array $lines, string $locale, string $namespace = '*'): void 127 | { 128 | $this->baseTranslator->addLines($lines, $locale, $namespace); 129 | } 130 | 131 | public function load(string $namespace, string $group, string $locale): void 132 | { 133 | $this->baseTranslator->load($namespace, $group, $locale); 134 | } 135 | 136 | public function addNamespace(string $namespace, string $hint): void 137 | { 138 | $this->baseTranslator->addNamespace($namespace, $hint); 139 | } 140 | 141 | public function addJsonPath(string $path): void 142 | { 143 | $this->baseTranslator->addJsonPath($path); 144 | } 145 | 146 | /** 147 | * @return array 148 | */ 149 | public function parseKey(string $key): array 150 | { 151 | /** @phpstan-ignore return.type */ 152 | return $this->baseTranslator->parseKey($key); 153 | } 154 | 155 | public function getSelector(): MessageSelector 156 | { 157 | return $this->baseTranslator->getSelector(); 158 | } 159 | 160 | public function setSelector(MessageSelector $selector): void 161 | { 162 | $this->baseTranslator->setSelector($selector); 163 | } 164 | 165 | public function getLoader(): Loader 166 | { 167 | return $this->baseTranslator->getLoader(); 168 | } 169 | 170 | public function locale(): string 171 | { 172 | return $this->getLocale(); 173 | } 174 | 175 | public function getLocale(): string 176 | { 177 | return $this->locale; 178 | } 179 | 180 | /** 181 | * @param string $locale 182 | */ 183 | public function setLocale($locale): void 184 | { 185 | $this->locale = $locale; 186 | $this->baseTranslator->setLocale($locale); 187 | } 188 | 189 | public function getFallback(): string 190 | { 191 | return $this->fallback; 192 | } 193 | 194 | public function setFallback(string $locale): void 195 | { 196 | $this->fallback = $locale; 197 | $this->baseTranslator->setFallback($locale); 198 | } 199 | } 200 | --------------------------------------------------------------------------------