Мультиязычность в Laravel: работа с данными от А до Я

Опубликовано 0 комментариев 1470 просмотров
Мультиязычность в Laravel: работа с данными от А до Я

Привет.

Я довольно часто пишу приложения, где необходимо предусмотреть работу с несколькими языками. Поначалу я не мог определиться с правильной структурой таблиц в хранилище и тем, как корректно получать данные в контроллерах. Для того, чтобы у вас сразу имелось готовое решение я решил написать данный мануал со своим вариантом решения.

Подготовка приложения

Для работы с мультиязычностью я использую готовый пакет mcamara/laravel-localization. Он удобен, "из коробки" имеет все необходимые настройки и несколько полезных утилит.

Структура таблиц

Изначально я делал общую таблицу с элементами, разделяя записи по языкам при помощи столбца lang. Однако довольно быстро понял, что это не самое лучшее решение. Правильней будет создать общую таблицу, скажем posts, где будут храниться общие для всех записей данные, - символьный код, индекс сортировки, время создания/изменения и т.д. А в другой таблице, post_localizations, хранить зависимые от языка данные - название, тексты и др.

В миграциях это выглядит так:

use Schema;
use Illuminate\Database\Schema\Blueprint;

Schema::create('posts', function(Blueprint $table) {
    $table->increments('id');
    $table->string('slug', 40)->unique();
    $table->string('image');
    $table->text('gallery');
    $table->timestamps();
});

Schema::create('post_localizations', function(Blueprint $table) {
    $table->increments('id');
    $table->integer('post_id')->unsigned()->index();
    $table->string('lang', 2);
    $table->string('name', 40);
    $table->string('preview_text');
    $table->text('detail_text');

    $table->foreign('post_id')->references('id')->on('posts')->onDelete('cascade');
});

Как видите, в posts хранятся те данные, которые не зависят от языка - слаг и изображения. post_localizations же предназначена для хранения языко-зависимых данных - названия, детального и превью текстов.

Строки в post_localizations связаны с основной таблицей колонкой post_id, а разделение по языкам происходит в lang. Именно такой структуры я придерживаюсь и рекомендую придерживаться вам, модифицируя лишь названия таблиц и колонки, зависимые от сущности.

Модификация модели

Придерживаясь правил, обозначенных выше мы получим 2 модели - Post и PostLocalization, Place и PlaceLocalization и т.п. Основная модель Post наследуется от немного модифицированной мною LocalizedModel.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;

/**
 * Class LocalizedModel
 * @package App\Models
 *
 * @property Model|null $localization
 * @property Collection|Model[] $localizations
 * @method static Builder|LocalizedModel withLocalization(string $locale)
 */
class LocalizedModel extends Model
{
    /**
     * @return \Illuminate\Database\Eloquent\Relations\HasOne
     */
    public function localization()
    {
        return $this->hasOne(
            $this->getLocalizationModelName()
        );
    }

    /**
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function localizations()
    {
        return $this->hasMany(
            $this->getLocalizationModelName()
        );
    }

    /**
     * @param Builder $query
     * @param string $locale
     */
    public function scopeWithLocalization(Builder $query, string $locale)
    {
        $filter = function($query) use ($locale) {
            /** @var Builder $query */
            $query->where('lang', $locale);
        };

        $query
            ->whereHas('localization', $filter)
            ->with([
                'localization' => $filter
            ]);
    }

    /**
     * @return string
     */
    private function getLocalizationModelName()
    {
        return get_class($this).'Localization';
    }
}
<?php

namespace App\Models;

/**
 * Class Post
 * @package App\Models
 */
class Post extends LocalizedModel
{
    protected $fillable = [
        'slug', 'image', 'gallery'
    ];

    protected $casts = [
        'gallery' => 'array'
    ];
}

В ней мы обозначаем две связи - hasOne localization для получения модели с одним языком (используется позже) и hasMany localizations для получения всех доступных языков (как правило, в админпанели). Кроме того я добавил скоуп whereLocalization(string $locale), обратившись к которому мы получим только данные, у которые имеются элементы с языком $locale.

Для зависимых от языка данных (PostLocalization) я наследуюсь от стандартной модели Eloquent.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

/**
 * Class PostLocalization
 * @package App\Models
 */
class PostLocalization extends Model
{
    protected $fillable = [
        'post_id', 'lang', 'name', 'preview_text', 'detail_text'
    ];

    public $timestamps = false;

    /**
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function post()
    {
        return $this->belongsTo(Post::class);
    }
}

Теперь когда необходимо будет получить данные с определенной локалью, достаточно будет обратиться к скоупу.

use LaravelLocalization;
use App\Models\Post;

$locale = LaraveLocazation::getCurrentLocale(); // текущий язык

// подгружаем первый элемент, у которого есть локализация $locale
$post = Post::withLocalization($locale)->firstOrFail();

/**
 * ...или если модель уже получена - получим локализованные данные через связь
 * @var Post $post
 * @var PostLocalization $localization
 */
$post = Post::first();
$localization = $post
    ->localization()
    ->where('lang', $locale)
    ->firstOrFail();

CRUD

Не уверен в необходимости данного пункта, но пусть будет. Возможно, его наличие облегчит кому-то работу.

Для работы с данными в админпанели у меня существует отдельный ресурc PostController. Разработав несколько схожих между собой интерфейсов, я стал придерживаться определенных правил.

Создание

Сначала создаем запись, затем через связь заполняем локализованными данными соответствующую таблицу.

/**
 * @param StoreRequest $request
 *
 * @return \Illuminate\Http\RedirectResponse
 */
public function store(StoreRequest $request)
{
    $post = new Post();
    // Заполняем модель данными
    $post->save();

    foreach($request->input('localization', []) as $k => $i) {
        /** @var PostLocalization $locale */
        $locale = $post->localizations()
            ->create($i + ['lang' => $k]);
    }

    return redirect()
        ->route('posts.edit', $post->id);
}

Обновление

Абсолютно аналогично созданию. При работе с локализованными данными в цикле я использую метод updateOrCreate вместо update, чтобы в случае если добавился новый язык после того, как запись была создана, не возникало ошибок и новые данные без проблем сохранялись.

/**
 * @param UpdateRequest $request
 * @param Post $post
 *
 * @return \Illuminate\Http\RedirectResponse
 */
public function update(UpdateRequest $request, Post $post)
{
    // Обновляем данные
    $post->update();

    // Обновляем (или создаем если не существует) локализованные данные
    foreach($request->input('localization', []) as $k => $i) {
        /** @var PostLocalization $locale */
        $locale = $post->localizations()
            ->updateOrCreate(['lang' => $k], $i);
    }

    return redirect()
        ->back();
}

Удаление

Так как все связи описаны в таблицах, достаточно удалить Post - все PostLocalization будут удалены вместе с ней.

/**
 * @param Post $post
 *
 * @return \Illuminate\Http\RedirectResponse
 * @throws \Exception
 */
public function destroy(Post $post)
{
    $post->delete();

    return redirect()
        ->route('posts.index');
}

В ответ на сообщение

Доступна разметка Markdown. А еще вы можете использовать крутой пак эмоций.

Нажимая на кнопку «Отправить» вы даете свое согласие на обработку персональных данных в соответствии с законом №152-ФЗ «О персональных данных» от 27.07.2006 и принимаете условия Политики конфеденциальности.