Skip to content

Internationalization (i18n)

Supported Languages

CodeLanguageStatus
enEnglishDefault
ruRussianSupported
viVietnameseSupported
ukUkrainianSupported

Quick Reference

Frontend Usage

tsx
import { useTranslation } from "react-i18next";

function MyComponent() {
  const { t } = useTranslation("common");
  return <button>{t("actions.save")}</button>;
}

// Multiple namespaces
const { t } = useTranslation(["common", "auth"]);
t("common:actions.cancel");
t("auth:login.title");

// Interpolation & pluralization
t("greeting", { name: "John" }); // "Hello, {{name}}"
t("time.minutesAgo", { count: 5 }); // Uses _one/_other suffixes

Backend Usage

php
// In controllers
return $this->sendSuccess($data, __('messages.pets.created'));

// With placeholders
return $this->sendError(__('messages.helper.city_country_mismatch', ['name' => $cityName]));

Translatable Models

Some models (City, Category, PetType) have translatable fields (e.g., name). These use the Spatie\Translatable package.

Model Setup

php
use Spatie\Translatable\HasTranslations;
use App\Models\Concerns\SerializesTranslatableAsString;

class City extends Model {
    use HasTranslations, SerializesTranslatableAsString;

    public $translatable = ['name'];
}

Working with Translations in PHP

php
// Set translations
$city->setTranslation('name', 'en', 'Hanoi');
$city->setTranslation('name', 'vi', 'Hà Nội');
$city->save();

// Or via array on creation
City::create([
    'name' => [
        'en' => 'Hanoi',
        'vi' => 'Hà Nội'
    ],
    'country' => 'VN'
]);

// Get translation (uses current app locale by default)
echo $city->name; // "Hà Nội" (if locale is 'vi')
echo $city->getTranslation('name', 'en'); // "Hanoi"

Querying Translatable Fields

php
// Search in specific locale using database-specific syntax (PostgreSQL)
$cities = City::where('name->vi', 'ilike', '%Hà Nội%')->get();

// Or via dedicated helper (recommended)
$cities = City::whereJsonContainsLocale('name', 'vi', 'Hà Nội')->get();

JSON Serialization

Models using SerializesTranslatableAsString will automatically convert translatable fields to a string of the current locale when converted to an array or JSON. This ensures the API remains simple for the frontend.

Runtime Locale Resolution (API Requests)

For normal API requests, locale resolution priority is:

  1. Accept-Language request header (explicit per-request override)
  2. Authenticated user profile locale (users.locale)
  3. App default locale (config('app.locale'))

When impersonating, the impersonator locale is still protected as highest priority.

This behavior is implemented in SetLocaleMiddleware and allows immediate language switching in the SPA without requiring a page refresh or re-login.

Search/Sort Fallback for Translatable JSONB Fields

For Category, City, and PetType, list/search ordering on translatable name now uses a locale fallback chain instead of a single key lookup.

  • Primary: current request locale
  • Fallback: config('app.fallback_locale')
  • Last fallback: en

Practically, this means users still get searchable/sortable results even when some records are missing a translation in the active locale.

The SQL helper for this logic is centralized in backend/app/Support/TranslatableSql.php.

Frontend Behavior for Translatable Dropdown Data

Translatable dropdown sources (pet types, categories, cities) are locale-aware at fetch/cache level:

  • React Query keys for translatable lists include the active locale
  • Imperative fetch flows re-run when locale changes

This prevents mixed-language dropdowns after switching language in the UI.

Pet types (“species”) note

Pet types are not frontend enum labels in this project.

  • The canonical list lives in the backend database table pet_types and is created/updated by backend/database/seeders/PetTypeSeeder.php.
  • The PetType model has a translatable name field (Spatie Translatable). API responses serialize name as a localized string based on the request Accept-Language header (the frontend sets this header automatically).
  • If you need to edit translations, do it via the admin panel at /admin using the locale switcher in the Pet Types resource, or by updating the seeder and re-running it.

The frontend pets.json previously contained a species.* translation map (e.g. species.cat). This was legacy and could get out of sync with the backend seed list; the UI should rely on the API-provided pet_type.name instead. Only species.other remains as a fallback label.


Notifications and Emails

Notifications are internationalized on both the backend (for content) and frontend (for UI).

Email Notifications

Email content is translated using the backend __() helper with specific attention to the recipient's preferred locale.

1. Subjects and Content: Translations are maintained in backend/lang/{locale}/messages.php under the emails key.

php
'emails' => [
    'subjects' => [
        'new_feature' => 'New Feature: :name',
    ],
    'common' => [
        'hello' => 'Hello :name,',
    ],
    'new_feature' => [
        'intro' => 'We launched a new feature!',
    ],
],

2. Notification Class: Set the $this->locale property in the toMail() method. Laravel's notification system uses this to localize subjects and markdown templates automatically. Use NotificationLocaleResolver to determine the best locale for the recipient.

php
public function toMail(object $notifiable): MailMessage
{
    // Resolve recipient locale (checks user preference, headers, or app default)
    $this->locale = app(NotificationLocaleResolver::class)->resolve($notifiable);

    return (new MailMessage)
        ->subject(__('messages.emails.subjects.new_feature', ['name' => $this->featureName], $this->locale))
        ->markdown('emails.new-feature', [
            'user' => $notifiable,
        ]);
}

3. Blade Templates: Markdown templates should use the __() helper:

blade
# {{ __('messages.emails.new_feature.title') }}
{{ __('messages.emails.common.hello', ['name' => $user->name]) }}

In-App Notifications (Bell)

Localization for in-app notifications happens at two levels:

1. Backend Labels (Metadata): Notification types and descriptions are defined in backend/lang/{locale}/messages.php under notifications.types.

php
'notifications' => [
    'types' => [
        'new_feature' => [
            'label' => 'New Feature',
            'description' => 'Check out the new feature we just added!',
        ],
    ],
],

2. Frontend UI: Notification UI elements (titles, buttons, relative time) are handled in common.json.

json
// common.json
"notifications": {
    "markAllRead": "Mark all as read",
    "empty": "No notifications yet"
},
"time": {
    "secondsAgoShort": "{{count}}s",
    "daysAgoShort": "{{count}}d"
}

File Structure

Frontend (frontend/src/i18n/)

locales/
├── en/                    # English (source)
├── ru/                    # Russian
├── uk/                    # Ukrainian
└── vi/                    # Vietnamese
    ├── common.json        # Shared UI (nav, actions, errors)
    ├── auth.json          # Login, register, password reset
    ├── pets.json          # Pet management
    ├── settings.json      # Settings pages
    ├── validation.json    # Form validation
    ├── helper.json        # Helper profiles
    └── placement.json     # Placement requests

Backend (backend/lang/)

├── en/
├── ru/
├── uk/
└── vi/
    ├── messages.php       # API response messages
    └── validation.php     # Validation messages

Adding New Translation Keys

  1. Add key to English file first
  2. Add same key to all other language files
  3. Use in code

Frontend example:

json
// locales/en/common.json
{ "newFeature": { "title": "New Feature" } }

Backend example:

php
// lang/en/messages.php
'new_feature' => ['created' => 'Feature created.'],

Adding a New Language

Example: Adding Vietnamese (vi)

Step 1: Backend

bash
# 1. Update config/locales.php
'supported' => ['en', 'ru', 'vi'],
'names' => [..., 'vi' => 'Tiếng Việt'],

# 2. Copy and translate files
cp -r backend/lang/en backend/lang/vi
# Then translate lang/vi/*.php

Step 2: Frontend

bash
# 1. Copy translation files
cp -r frontend/src/i18n/locales/en frontend/src/i18n/locales/vi
typescript
// 2. Update src/i18n/index.ts

// Add imports
import viCommon from './locales/vi/common.json'
import viAuth from './locales/vi/auth.json'
import viPets from './locales/vi/pets.json'
import viSettings from './locales/vi/settings.json'
import viValidation from './locales/vi/validation.json'
import viHelper from './locales/vi/helper.json'
import viPlacement from './locales/vi/placement.json'

// Update supportedLocales
export const supportedLocales = ['en', 'ru', 'vi'] as const

// Update localeNames
export const localeNames = { ..., vi: 'Tiếng Việt' }

// Add to resources
vi: {
  common: viCommon,
  auth: viAuth,
  pets: viPets,
  settings: viSettings,
  validation: viValidation,
  helper: viHelper,
  placement: viPlacement,
}

Step 3: Update Language Switcher Keys

Add new language name to language object in all existing common.json files:

json
// en/common.json
"language": { ..., "vi": "Vietnamese" }

// ru/common.json
"language": { ..., "vi": "Вьетнамский" }

// vi/common.json
"language": { "en": "Tiếng Anh", "ru": "Tiếng Nga", "vi": "Tiếng Việt" }

Step 4: Translate & Verify

bash
# Translate all files in locales/vi/*.json

# Then verify
cd frontend
bun run i18n:check    # Check for missing keys
bun run typecheck     # TypeScript check
bun run test:minimal  # Run tests

Step 5: Update Database Translations

For models with translatable fields (PetType, Category, City), you must update the seeders and re-run them to apply translations to existing records.

  1. Update Seeders: Add the new language translations to PetTypeSeeder.php, CategorySeeder.php, and CitySeeder.php.

    php
    // Example in PetTypeSeeder.php
    ['en' => 'Cat', 'ru' => 'Кошка', 'vi' => 'Mèo']
  2. Ensure updateOrCreate: Seeders should use updateOrCreate or update to ensure existing records get the new translation arrays.

  3. Run Seeders:

    bash
    docker compose exec backend php artisan db:seed --class=CategorySeeder
    docker compose exec backend php artisan db:seed --class=CitySeeder
    docker compose exec backend php artisan db:seed --class=PetTypeSeeder

Pluralization Notes

LanguageFormsSuffixes
English2_one, _other
Russian4_one, _few, _many, _other
Ukrainian4_one, _few, _many, _other
Vietnamese1No suffix needed

Maintenance Commands

bash
bun run i18n:check            # Check missing/unused/invalid keys
bun run i18n:unused           # Find unused keys
bun run i18n:missing          # Find missing translations
bun run i18n:clean-placeholders  # Remove __STRING_NOT_TRANSLATED__

Common Issues

ProblemSolution
Translation not showingCheck key exists, namespace is correct, file is imported in index.ts
Language switcher missing new languageAdd language.XX key to ALL existing common.json files
Accept-Language header not sentCheck Axios interceptor in src/api/axios.ts
User locale not persistingVerify user is authenticated, check /api/user/locale endpoint
Mixed-language dropdown optionsEnsure translatable list fetches are locale-scoped and refetched on language change

Key Files

Backend:

  • config/locales.php - Supported locales config
  • app/Http/Middleware/SetLocaleMiddleware.php - Locale detection
  • app/Support/TranslatableSql.php - Locale fallback SQL helper for translatable JSONB fields
  • app/Services/Notifications/NotificationLocaleResolver.php - Resolves notification locales
  • lang/{locale}/messages.php - API messages
  • lang/{locale}/validation.php - Validation messages

Frontend:

  • src/i18n/index.ts - i18next configuration
  • src/i18n/locales/{locale}/*.json - Translation files
  • src/components/LanguageSwitcher.tsx - Language selector
  • src/hooks/useLocaleSync.ts - Syncs user locale preference
  • src/api/axios.ts - Accept-Language header interceptor