├── .gitignore ├── src ├── config │ └── multilingual.php ├── TranslationsManager.php ├── MultilingualServiceProvider.php └── Translatable.php ├── tests ├── models │ ├── MULTILINGUAL_TEST__UNTRANSLATABLE_PLANET_MODEL.php │ └── MULTILINGUAL_TEST_PLANET_MODEL.php ├── TestCase.php ├── ValidationTest.php └── TranslationsTest.php ├── phpunit.xml ├── composer.json ├── LICENSE.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | .idea/ 4 | .phpunit.result.cache 5 | -------------------------------------------------------------------------------- /src/config/multilingual.php: -------------------------------------------------------------------------------- 1 | ['en'], 7 | 'fallback_locale' => 'en', 8 | 9 | 10 | ]; 11 | -------------------------------------------------------------------------------- /tests/models/MULTILINGUAL_TEST__UNTRANSLATABLE_PLANET_MODEL.php: -------------------------------------------------------------------------------- 1 | 'integer', 16 | 'name' => 'array', 17 | ]; 18 | 19 | } -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests/ 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/models/MULTILINGUAL_TEST_PLANET_MODEL.php: -------------------------------------------------------------------------------- 1 | 'integer', 17 | 'name' => 'array', 18 | 'order' => 'integer', 19 | ]; 20 | 21 | } -------------------------------------------------------------------------------- /src/TranslationsManager.php: -------------------------------------------------------------------------------- 1 | translations = $translations; 15 | } 16 | 17 | /** 18 | * @param $key 19 | * @return mixed 20 | */ 21 | public function __get($key) 22 | { 23 | return @$this->translations[$key] ?: ''; 24 | } 25 | 26 | /** 27 | * Return an array of available locales with values 28 | * 29 | * @return array 30 | */ 31 | public function toArray() 32 | { 33 | return $this->translations; 34 | } 35 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "themsaid/laravel-multilingual", 3 | "description": "Easy multilingual laravel models", 4 | "keywords": ["laravel", "multilingual", "translation"], 5 | "homepage": "https://github.com/themsaid/laravel-multilingual", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Mohamed Said", 10 | "email": "theMohamedSaid@gmail.com" 11 | } 12 | ], 13 | "require": { 14 | "illuminate/support": "^5.1||^6.0", 15 | "php" : ">=5.3.0" 16 | }, 17 | "require-dev": { 18 | "phpunit/phpunit" : "^8.0", 19 | "orchestra/testbench": "^4.0" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "Themsaid\\Multilingual\\": "src" 24 | }, 25 | "classmap": [ 26 | "tests" 27 | ] 28 | }, 29 | "extra": { 30 | "laravel": { 31 | "providers": [ 32 | "Themsaid\\Multilingual\\MultilingualServiceProvider" 33 | ] 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Mohamed Said 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 13 | > all 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 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/MultilingualServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 21 | __DIR__.'/config/multilingual.php' => config_path('multilingual.php'), 22 | ]); 23 | 24 | 25 | $this->app['validator']->extendImplicit('translatable_required', function ($attribute, $value, $parameters) use ($systemLocales) { 26 | if (! is_array($value)) { 27 | return false; 28 | } 29 | 30 | // Get only the locales that has a value and exists in the system locales array 31 | $locales = array_filter(array_keys($value), function ($locale) use ($value, $systemLocales) { 32 | return @$value[$locale] && in_array($locale, $systemLocales); 33 | }); 34 | 35 | foreach ($systemLocales as $systemLocale) { 36 | if (! in_array($systemLocale, $locales)) { 37 | return false; 38 | } 39 | } 40 | 41 | return true; 42 | }); 43 | } 44 | 45 | /** 46 | * Register any package services. 47 | * 48 | * @return void 49 | */ 50 | public function register() 51 | { 52 | $this->mergeConfigFrom( 53 | __DIR__.'/config/multilingual.php', 'multilingual' 54 | ); 55 | } 56 | } -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | prepareDatabase(); 24 | } 25 | 26 | public function test_database_setup() 27 | { 28 | $this->assertTrue(Schema::hasTable('planets')); 29 | } 30 | 31 | /** 32 | * Define environment setup. 33 | * 34 | * @param \Illuminate\Foundation\Application $app 35 | * @return void 36 | */ 37 | protected function getEnvironmentSetUp($app) 38 | { 39 | $app['config']->set('multilingual.locales', ['en', 'sp']); 40 | $app['config']->set('multilingual.fallback_locale', 'en'); 41 | 42 | $app['config']->set('database.default', 'mysql'); 43 | $app['config']->set('database.connections.mysql', [ 44 | 'driver' => 'mysql', 45 | 'host' => 'localhost', 46 | 'database' => $this->DBName, 47 | 'username' => $this->DBUsername, 48 | 'password' => $this->DBPassword, 49 | 'charset' => 'utf8', 50 | 'collation' => 'utf8_unicode_ci', 51 | 'prefix' => '', 52 | 'strict' => false, 53 | ]); 54 | } 55 | 56 | /** 57 | * Loading package service provider 58 | * 59 | * @param \Illuminate\Foundation\Application $app 60 | * @return array 61 | */ 62 | protected function getPackageProviders($app) 63 | { 64 | return ['Themsaid\Multilingual\MultilingualServiceProvider']; 65 | } 66 | 67 | /** 68 | * Get package aliases. 69 | * 70 | * @param \Illuminate\Foundation\Application $app 71 | * 72 | * @return array 73 | */ 74 | protected function getPackageAliases($app) 75 | { 76 | return [ 77 | 'Schema' => 'Illuminate\Database\Schema\Blueprint' 78 | ]; 79 | } 80 | 81 | public function prepareDatabase() 82 | { 83 | if ( ! Schema::hasTable('planets')) { 84 | Schema::create('planets', function (Blueprint $table) { 85 | $table->increments('id'); 86 | $table->string('name'); 87 | $table->string('order'); 88 | }); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Translatable.php: -------------------------------------------------------------------------------- 1 | translatable)) { 19 | // We check if the attribute is translatable and return a proper 20 | // value based on the current locale 21 | if (in_array($key, $this->translatable)) { 22 | return $this->getValueOfCurrentLocaleForKey($key); 23 | }; 24 | 25 | // We check if the attribute is expected to return the 26 | // TranslationManager's object 27 | $translatableKey = str_replace('Translations', '', $key); 28 | if (in_array($translatableKey, $this->translatable)) { 29 | return new TranslationsManager( 30 | $this->getAttributeValue($translatableKey) 31 | ); 32 | } 33 | } 34 | 35 | return parent::getAttribute($key); 36 | } 37 | 38 | /** 39 | * Get the value of current locale for $key attribute, if not found it falls 40 | * back to the fallback locale or the first locale in array. 41 | * 42 | * @param $key 43 | * @return string 44 | */ 45 | public function getValueOfCurrentLocaleForKey($key) 46 | { 47 | $translations = $this->getAttributeValue($key); 48 | $currentLocale = config('app.locale'); 49 | $fallbackLocale = config('multilingual.fallback_locale'); 50 | 51 | if ( ! $translations) return ""; 52 | 53 | if ( ! @$translations[$currentLocale]) { 54 | return @$translations[$fallbackLocale] ?: ''; 55 | }; 56 | 57 | return @$translations[$currentLocale]; 58 | } 59 | 60 | /** 61 | * Alter the default Illuminate\Database\Eloquent\Model method for checking if attribute 62 | * should be casted as JSON, we check if the attribute is translatable & cast 63 | * it as JSON even if it's not casted as JSON in Model::$casts 64 | * 65 | * @param string $key 66 | * @return bool 67 | */ 68 | protected function isJsonCastable($key) 69 | { 70 | if (isset($this->translatable) && in_array($key, $this->translatable)) { 71 | return true; 72 | } 73 | 74 | return parent::isJsonCastable($key); 75 | } 76 | 77 | /** 78 | * Alter the default behaviour when it comes to using json_encode for model attributes, 79 | * this will save the json as UTF-8 to the database instead of escaping characters. 80 | * 81 | * @param array $value 82 | * @return string 83 | */ 84 | protected function asJson($value) 85 | { 86 | return json_encode($value, JSON_UNESCAPED_UNICODE); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tests/ValidationTest.php: -------------------------------------------------------------------------------- 1 | ''], 11 | ['name' => 'translatable_required'] 12 | ); 13 | 14 | $this->assertTrue($validator->messages()->has('name')); 15 | } 16 | 17 | public function test_validation_fails_if_required_but_empty_array() 18 | { 19 | $validator = Validator::make( 20 | ['name' => []], 21 | ['name' => 'translatable_required'] 22 | ); 23 | 24 | $this->assertTrue($validator->messages()->has('name')); 25 | } 26 | 27 | public function test_validation_fails_if_required_but_string() 28 | { 29 | $validator = Validator::make( 30 | ['name' => 'This is not cool'], 31 | ['name' => 'translatable_required'] 32 | ); 33 | 34 | $this->assertTrue($validator->messages()->has('name')); 35 | } 36 | 37 | public function test_validation_fails_if_required_and_has_correct_keys_but_empty_values() 38 | { 39 | $validator = Validator::make( 40 | ['name' => ['en' => '']], 41 | ['name' => 'translatable_required'] 42 | ); 43 | 44 | $this->assertTrue($validator->messages()->has('name')); 45 | } 46 | 47 | public function test_validation_fails_if_required_and_has_missing_translations() 48 | { 49 | $validator = Validator::make( 50 | ['name' => ['en' => 'One']], 51 | ['name' => 'translatable_required'] 52 | ); 53 | 54 | $this->assertTrue($validator->messages()->has('name')); 55 | } 56 | 57 | public function test_validation_fails_if_required_and_has_empty_translations() 58 | { 59 | $validator = Validator::make( 60 | ['name' => ['en' => 'One', 'sp' => '']], 61 | ['name' => 'translatable_required'] 62 | ); 63 | 64 | $this->assertTrue($validator->messages()->has('name')); 65 | } 66 | 67 | public function test_validation_succeed_if_required_and_OK() 68 | { 69 | $validator = Validator::make( 70 | ['name' => ['en' => 'One', 'sp' => 'Uno']], 71 | ['name' => 'translatable_required'] 72 | ); 73 | 74 | $this->assertFalse($validator->messages()->has('name')); 75 | } 76 | 77 | public function test_only_specific_locales_required() 78 | { 79 | $validator = Validator::make( 80 | ['name' => ['en' => 'One', 'sp' => 'Uno']], 81 | ['name.en' => 'required'] 82 | ); 83 | $this->assertTrue($validator->passes()); 84 | 85 | $validator = Validator::make( 86 | ['name' => ['sp' => 'Uno']], 87 | ['name.en' => 'required'] 88 | ); 89 | $this->assertFalse($validator->passes()); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel 5 Multilingual Models 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/themsaid/laravel-multilingual.svg?style=flat-square)](https://packagist.org/packages/themsaid/laravel-multilingual) 4 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/themsaid/laravel-multilingual.svg?style=flat-square)](https://packagist.org/packages/themsaid/laravel-multilingual) 6 | 7 | 8 | This laravel package makes Eloquent Models attributes translatable without the need to separate database tables for translation values. 9 | 10 | You simply call `$country->name` and you get a value based on your application's current locale. 11 | 12 | You can also call `$country->nameTranslations->en` to get the value of a specific locale. 13 | 14 | You can check all the translations of a given attributes as easy as `$country->nameTranslations->toArray()`. 15 | 16 | ## Installation 17 | 18 | Begin by installing the package through Composer. Run the following command in your terminal: 19 | 20 | ``` 21 | composer require themsaid/laravel-multilingual 22 | ``` 23 | 24 | Once composer is done, add the package service provider in the providers array in `config/app.php` 25 | 26 | ``` 27 | Themsaid\Multilingual\MultilingualServiceProvider::class 28 | ``` 29 | 30 | Finally publish the config file: 31 | 32 | ``` 33 | php artisan vendor:publish 34 | ``` 35 | 36 | That's all, you are now good to go. 37 | 38 | # Usage 39 | 40 | First you need to make sure that the translatable attributes has a mysql field type of text or json, if you are building the database from a migration file you may do this: 41 | 42 | ```php 43 | increments('id'); 48 | $table->json('name'); 49 | }); 50 | ``` 51 | 52 | Now that you have the database ready to save a JSON string, you need to prepare your models: 53 | 54 | ```php 55 | 'array']; 64 | } 65 | ``` 66 | 67 | - Add the `Translatable` trait to your model class 68 | - Add a public class property `$translatable` as an array that holds the names of the translatable fields in your model. 69 | - Remember to cast the translatable attribute as 'array' in the `$casts` property of the model. 70 | 71 | Now our model has the `name` attribute translatable, so on creating a new Model you may specify the name field as follow: 72 | 73 | ```php 74 | [ 78 | 'en' => "Spain", 79 | 'sp' => 'España' 80 | ] 81 | ]); 82 | ``` 83 | 84 | It'll be automatically converted to a JSON string and saved in the name field of the database, you can later retrieve the name like this: 85 | 86 | ``` 87 | $country->name 88 | ``` 89 | 90 | This will return the country name based on the current locale, if the current locale doesn't have a value then the `fallback_locale` defined in the config file will be used. 91 | 92 | In case nothing can be found an empty string will be returned. 93 | 94 | You may also want to return the value for a specific locale, you can do that using the following syntax: 95 | 96 | ``` 97 | $country->nameTranslations->en 98 | ``` 99 | 100 | This will return the English name of the country. 101 | 102 | To return an array of all the available translations you may use: 103 | 104 | ``` 105 | $country->nameTranslations->toArray() 106 | ``` 107 | 108 | # Validation 109 | You can use the new array validation features released with laravel 5.2 to validate the presence of specific locales: 110 | 111 | ```php 112 | ['en'=>'One', 'sp'=>'Uno']], 116 | ['name.en' => 'required'] 117 | ); 118 | ``` 119 | 120 | However a validation rule is included in this package that deals with requiring all the validations to be provided: 121 | 122 | ```php 123 | ['en'=>'One', 'sp'=>'Uno']], 127 | ['name' => 'translatable_required'] 128 | ); 129 | ``` 130 | 131 | The `translatable_required` rule will make sure all the values of the available locales are set. 132 | 133 | You may define the available locales as well as the fallback_locale from the package config file. 134 | 135 | Now you only need to add the translated message of our new validation rule, add this to the `validation.php` translation file: 136 | 137 | ``` 138 | 'translatable_required' => 'The :attribute translations must be provided.', 139 | ``` 140 | 141 | # Queries 142 | If you're using MySQL 5.7 or above, it's recommended that you use the json data type for housing translations in the Database, 143 | this will allow you to query these columns like this: 144 | 145 | ```php 146 | Company::whereRaw('name->"$.en" = \'Monsters Inc.\'')->orderByRaw('specs->"$.founded_at"')->get(); 147 | ``` 148 | 149 | However in laravel 5.2.23 and above you can use the fluent syntax: 150 | 151 | ```php 152 | Company::where('name->en', 'Monsters Inc.')->orderBy('specs->founded_at')->get(); 153 | 154 | ``` 155 | -------------------------------------------------------------------------------- /tests/TranslationsTest.php: -------------------------------------------------------------------------------- 1 | 'Mercury' 19 | ]); 20 | 21 | $this->assertEquals('Mercury', $planet->name); 22 | } 23 | 24 | public function test_translatable_attribute_casted_as_NOT_ARRAY_is_saved_as_json_still() 25 | { 26 | $planet = Planet::create([ 27 | 'order' => [ 28 | 'en' => 'One', 29 | 'sp' => 'Uno' 30 | ] 31 | ]); 32 | 33 | $this->assertJson($planet->getOriginal('order')); 34 | } 35 | 36 | public function test_translatable_attribute_return_value_of_current_locale() 37 | { 38 | $planet = Planet::create([ 39 | 'name' => [ 40 | 'en' => 'Mercury', 41 | 'sp' => 'Mercurio' 42 | ] 43 | ]); 44 | 45 | config(['app.locale' => 'sp']); 46 | 47 | $this->assertEquals('Mercurio', $planet->name); 48 | } 49 | 50 | public function test_translatable_attribute_return_empty_string_if_no_translations() 51 | { 52 | $planet = Planet::create(); 53 | 54 | config(['app.locale' => 'en']); 55 | 56 | $this->assertEquals('', $planet->name); 57 | } 58 | 59 | public function test_translatable_attribute_return_default_value_if_current_locale_not_exist() 60 | { 61 | $planet = Planet::create([ 62 | 'name' => [ 63 | 'en' => 'Mercury', 64 | 'sp' => 'Mercurio' 65 | ] 66 | ]); 67 | 68 | config(['multilingual.fallback_locale' => 'sp']); 69 | config(['app.locale' => 'ar']); 70 | 71 | $this->assertEquals('Mercurio', $planet->name); 72 | } 73 | 74 | public function test_translatable_attribute_return_fallback_value_if_current_locale_empty() 75 | { 76 | $planet = Planet::create([ 77 | 'name' => [ 78 | 'en' => '', 79 | 'sp' => 'Mercurio' 80 | ] 81 | ]); 82 | 83 | config(['multilingual.fallback_locale' => 'sp']); 84 | config(['app.locale' => 'en']); 85 | 86 | $this->assertEquals('Mercurio', $planet->name); 87 | } 88 | 89 | public function test_translatable_attribute_return_empty_value_if_current_and_fallback_locale_empty() 90 | { 91 | $planet = Planet::create([ 92 | 'name' => [ 93 | 'en' => '', 94 | 'sp' => '', 95 | 'fr' => 'No Idea' 96 | ] 97 | ]); 98 | 99 | config(['multilingual.fallback_locale' => 'sp']); 100 | config(['app.locale' => 'en']); 101 | 102 | $this->assertEquals('', $planet->name); 103 | } 104 | 105 | public function test_returning_array_of_all_translations() 106 | { 107 | $planetName = [ 108 | 'en' => 'Mercury', 109 | 'sp' => 'Mercurio' 110 | ]; 111 | 112 | $planet = Planet::create([ 113 | 'name' => $planetName 114 | ]); 115 | 116 | config(['app.locale' => 'en']); 117 | 118 | $this->assertEquals($planetName, $planet->nameTranslations->toArray()); 119 | } 120 | 121 | public function test_returning_the_value_of_specific_locale() 122 | { 123 | $planetName = [ 124 | 'en' => 'Mercury', 125 | 'sp' => 'Mercurio' 126 | ]; 127 | 128 | $planet = Planet::create([ 129 | 'name' => $planetName 130 | ]); 131 | 132 | config(['app.locale' => 'en']); 133 | 134 | $this->assertEquals('Mercurio', $planet->nameTranslations->sp); 135 | } 136 | 137 | public function test_returning_empty_string_if_NO_value_of_specific_locale() 138 | { 139 | $planetName = [ 140 | 'en' => 'Mercury', 141 | 'sp' => '' 142 | ]; 143 | 144 | $planet = Planet::create([ 145 | 'name' => $planetName 146 | ]); 147 | 148 | config(['app.locale' => 'en']); 149 | 150 | $this->assertEquals('', $planet->nameTranslations->sp); 151 | } 152 | 153 | public function test_returning_empty_string_if_value_not_array() 154 | { 155 | $planetId = Planet::insertGetId([ 156 | 'name' => 'Earth' 157 | ]); 158 | 159 | $planet = Planet::find($planetId); 160 | 161 | config(['app.locale' => 'en']); 162 | 163 | $this->assertEquals('', $planet->name); 164 | } 165 | 166 | public function test_returning_empty_string_for_a_specific_locale_if_value_not_array() 167 | { 168 | $planetId = Planet::insertGetId([ 169 | 'name' => 'Earth' 170 | ]); 171 | 172 | $planet = Planet::find($planetId); 173 | 174 | config(['app.locale' => 'en']); 175 | 176 | $this->assertEquals('', $planet->nameTranslations->sp); 177 | } 178 | 179 | } 180 | --------------------------------------------------------------------------------