Opublikowano 7 min. czytania

Kompleksowe wdrożenie wielojęzyczności do webaplikacji wymaga skupienia się na trzech głównych aspektach: routingu, tłumaczeniu interfejsu użytkownika oraz modyfikacji struktury bazy danych. W tym artykule opiszę każdy z nich i zwrócę uwagę na dobre praktyki, których warto się trzymać.
fot. maxxyustas (elements.envato.com)

Przygotowanie routingu

Każda podtrona powinna posiadać unikalny URL dla danego języka (o tym dlaczego to jest ważne, dowiesz się z dalszej części tekstu), np:

  • /en/contact - wersja angielska,
  • /es/contact - wersja hiszpańska,
  • /pl/contact - wersja polska.

W wersji bardziej przyjaznej SEO możemy pokusić się o tłumaczenia adresów:

  • /en/contact
  • /es/contactenos
  • /pl/kontakt

Choć oba warianty można uzyskać bezpośrednio odpowiednimi regułami routingu, polecam posłużyć się paczką mcamara/laravel-localization. Pozwala ona nie tylko na łatwą obsługę obu powyższych wariantów, ale oferuje przy tym także sporo innych przydanych funkcji (m.in. wygodne tłumaczenie ścieżek, helpery do generowania linków, czy detekcję języka na podstawie ustawień przeglądarki). Po wykonaniu wszystkich kroków instalacyjnych, wystarczy wszystkie wielojęzyczne ścieżki routingu umieścić w specjalnej grupie:

// routes/web.php

Route::group(['prefix' => LaravelLocalization::setLocale(), 'middleware' => ['localeSessionRedirect']], function()
{
    Route::get('/', 'PagesController@home')->name('pages.home');
    Route::get('contact', 'PagesController@contact')->name('pages.contact');
});

Daje nam to możliwość używania prefiksów w ścieżkach dla języków wymienionych w pliku config/laravellocalization.php w tablicy supportedLocales:

// config/laravellocalization.php

'supportedLocales' => [
  'en' => ['name' => 'English', 'script' => 'Latn', 'native' => 'English', 'regional' => 'en_GB'],
  'es' => ['name' => 'Spanish', 'script' => 'Latn', 'native' => 'español', 'regional' => 'es_ES'],
  'pl' => ['name' => 'Polish', 'script' => 'Latn', 'native' => 'polski', 'regional' => 'pl_PL']
]

W powyższym przykładzie definicji grupy routingu warto zwrócić uwagę na obecność middleware localeSessionRedirect, dzięki któremu jeśli obecnym (zapisanym w sesji) językiem jest np. es, to po wejściu na stronę https://example.com/contact nastąpi przekierowanie na https://example.com/es/contact. Natomiast jeśli żaden język nie jest zapisany w sesji - nastąpi jego rozpoznanie na podstawie nagłówka HTTP Accept-Language (o ile useAcceptLanguageHeader w config/laravellocalization.php jest ustawione na true).

Jeśli chcesz tłumaczyć ścieżki, dla każdego języka utwórz plik resources/lang/**/routes.php z ich tłumaczeniami, np:

// resources/lang/pl/routes.php

return [
  'contact' => 'kontakt',
  'posts' => 'posty/{slug}
];

Następnie użyj ich w routes/web.php:

// routes/web.php

Route::group(['prefix' => LaravelLocalization::setLocale(), 'middleware' => [ 'localize' ]], function ()
{
  Route::get(LaravelLocalization::transRoute('routes.contact'), 'ContactController@index');
  Route::get(LaravelLocalization::transRoute('routes.posts'), 'PostsController@show');
});

Zwróć uwagę, że w Route::group() musi znajdować się middleware localize.

Tłumaczenie interfejsu użytkownika

Tutaj sprawa jest bardzo prosta, gdyż Laravel oferuje możliwość tłumaczenia stringów na dwa sposoby. Pierwszy z nich polega na tworzeniu plików z tablicami asocjacyjnymi zawierającymi "krótkie" klucze:

// resources/lang/en/contact.php

return [
    'header' => [
        'title' => 'Contact',
        'subtitle' => 'Hello :name, fell free to contact us!'
    ],

    'button_text' => 'Contact form'
];

Stringi z tak przygotowanego pliku możemy wyciągać za pomocą helpera __() lub dyrektywy @lang (dotyczy tylko szablonów Blade) w następujący sposób:

// resources/views/contact.blade.php

<header>
  <h1>{{ __('contact.header.title') }}</h1>
  <p>{{ __('contact.header.subtitle', ['name' => 'John']) }}</p>
</header>

<a href="#">@lang('contact.button.text')</a>

Jeśli klucz nie zostanie odnaleziony w pliku dla aktualnie używanej wersji językowej, nastąpi wyszukiwanie go w pliku właściwym dla języka wskazanego w fallback_locale wewnątrz config/app.php.

Drugim sposobem jest używanie całych stringów jako kluczy wewnątrz pliku JSON. Na przykład dla niemieckiej wersji językowej możesz utworzyć plik resources/lang/de.json o zawartości:

// resources/lang/de.json

{
  "Your message has been sent successfully.": "Ihre Nachricht wurde erfolgreich gesendet."
}

Tłumaczenie wyświetla się w taki sam sposób:

echo __('Your message has been sent successfully.');

Osobiście preferuję sposób pierwszy (krótkie klucze w wielu plikach), gdyż rozsądny podział na pliki i możliwość zagnieżdżania tablic pozwala nie zgubić kontekstu przy wykonywaniu tłumaczeń. Nie występują wtedy również konflikty, np. angielski wyraz "contact" w języku polskim może oznaczać nie tylko "kontakt", ale również czasownik "kontaktować (się)".

Po więcej szczegółów dot. tłumaczeń stringów pozostaje mi odesłać Cię do oficjalnej dokumentacji.

Struktura bazy danych (MySQL)

Istnieje kilka możliwości tworzenia wielojęzycznej struktury danych. Każda z nich ma swoje wady i zalety. Ja polecę Ci sposób, który z powodzeniem wdrożyłem w dziesiątkach projektów małych i dużych.

Bardzo dobrym sposobem jest skorzystanie z paczki spatie/laravel-translatable, która zawiera trait'a umożliwiającego wygodne zarządzanie tłumaczeniami w modelach Eloquenta.

Od strony bazodanowej, dane w wielojęzycznych polach tabel trzymane są w formacie JSON. Przykładowa tabela "products" z takimi polami wygląda tak:

id | name | description | price
---
1 | {"pl": "Czarna bluza", "en": "Black hoodie"} | {"pl": "Wykonana w 100% z bawełny.", "en": "Made of 100% cotton."} | 89.00

Początkowo byłem bardzo sceptycznie nastawiony do tego rozwiązania. Wyznawałem wtedy zasadę "jeden język = jeden rekord" (z polem language_id) lub tworzyłem osobną tabelę z samymi tłumaczeniami (product_translations). Oba te rozwiązania okazywały się uciążliwe w aplikacjach, które musiały ciągle się rozwijać, ale wydawało mi się, że trzymanie danych w JSON jeszcze bardziej utrudni mi pobieranie danych z bazy, przeszukwanie pól będzie uciążliwe i pojawią się problemy z wydajnością. Nic bardziej mylnego. Trait, który oferuje laravel-translatable posiada szereg wygodnych metod do obsługi tłumaczeń. Ponadto MySQL od wersji 5.7 obsługuje już natywnie format JSON, dzięki czemu w zapytaniach można bardzo łatwo odwoływać się do struktury JSON. Spójrz na poniższe przykłady.

Przygotowanie modelu

Do klasy modelu należy dodać trait'a Spatie\Translatable\HasTranslations, a następnie w publicznej właściwości $translatable w tablicy zdefiniować pola tabeli, które mają być wielojęzyczne.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;

class Product extends Model
{
    use HasTranslations;

    public $translatable = ['name', 'description'];
}

Tworzenie i aktualizacja tłumaczeń

Można zrealizować to na trzy sposoby. Najprościej po prostu ustawić wartość właściwości obiektu dla aktualnie używanego języka:

$product->name = 'New name';
$product->save();

Zapisywanie tłumaczeń dla wielu języków na raz:

$product->name = ['en' => 'New name', 'pl' => 'Nowa nazwa'];
$product->save();

Można także użyć metody setTranslation:

$product->setTranslation('name', 'en', 'Other name');
$product->save();

Pobieranie tłumaczeń

Gdy chcemy pobrać wartość dla aktualnie używanego języka aplikacji, wystarczy po prostu zrobić:

$product->name;

Aby pobrać tłumaczenie w konkretnym języku, można użyć metody getTranslation:

$product->getTranslation('name', 'pl');

Natomiast tablicę ze wszystkimi tłumaczeniami pobieramy tak:

$product->getTranslations('name');

Co warto wiedzieć przy wdrażaniu wielojęzyczności?

  1. Nie wyświetlaj różnych wersji językowych strony pod tym samym adresem URL. Jeśli zapiszesz wybrany przez użytkownika język (w cookie, localStorage itp.) tylko po to, aby wiedzieć w jakim języku załadować treść i nie utworzysz różnych adresów dla każdej wersji, roboty indeksujące stronę nie zaindeksują wszystkich wersji językowych lub gorzej - zaindeksują za każdym razem inną wersję językową! Dlatego właśnie w ścieżkach stosuje się prefiks językowy.

  2. Zadbaj o to, aby layout aplikacji nie był tworzony z pikselową precyzją pod jeden język. Weź pod uwagę, że tekst, który w języku angielskim zmieści się w boksie 400x50 px, niekoniecznie pomieści jego niemieckie, czy hiszpańskie tłumaczenie. Upewnij się, że każdy kontener na treść może pomieścić tekst o różnej długości.

  3. Przygotuj się na ewentualne różnice w wyświetlaniu dat, czasu i walut. W USA najczęściej używa się się formatu daty miesiąc/dzień/rok, kiedy w Europie i Wielkiej Brytanii używa się dzień/miesiąc/rok, co może prowadzić do bardzo wielu nieporozumień. Podobne różnice występują również przy wyświetlaniu czasu (zegar 12- lub 24-godzinny) i oczywiście walut. Dlatego wartości te nie powinny być zakodowane na sztywno.

  4. Pamiętaj o atrybucie lang w HTML. Dzięki niemu silniki wyszukiwarek będą mogły precyzyjniej generować wyniki wyszukiwania, ponadto różne aplikacje przetwarzające tekst wyświetlany na ekranie będą mogły np. poprawnie wymówić wyświetlany tekst.

  5. Podlinkuj alternatywne wersje strony w innych językach za pomocą <link rel="alternate" hreflang="KOD_JĘZYK" url="URL_STRONY >. Dzięki temu roboty indeksujące nie tylko dowiedzą się o dodatkowej treści do zaindeksowania, ale również przeglądarki będą mogły zaproponować użytkownikowi przejście na wersję językową odpowiednią dla jego ustawień regionalnych.