Internationalization (i18n)
Supported Languages
| Code | Language | Status |
|---|---|---|
en | English | Default |
ru | Russian | Supported |
vi | Vietnamese | Supported |
uk | Ukrainian | Supported |
Quick Reference
Frontend Usage
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 suffixesBackend Usage
// 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
use Spatie\Translatable\HasTranslations;
use App\Models\Concerns\SerializesTranslatableAsString;
class City extends Model {
use HasTranslations, SerializesTranslatableAsString;
public $translatable = ['name'];
}Working with Translations in 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
// 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:
Accept-Languagerequest header (explicit per-request override)- Authenticated user profile locale (
users.locale) - 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_typesand is created/updated bybackend/database/seeders/PetTypeSeeder.php. - The
PetTypemodel has a translatablenamefield (Spatie Translatable). API responses serializenameas a localized string based on the requestAccept-Languageheader (the frontend sets this header automatically). - If you need to edit translations, do it via the admin panel at
/adminusing the locale switcher in thePet Typesresource, 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.
'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.
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:
# {{ __('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.
'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.
// 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 requestsBackend (backend/lang/)
├── en/
├── ru/
├── uk/
└── vi/
├── messages.php # API response messages
└── validation.php # Validation messagesAdding New Translation Keys
- Add key to English file first
- Add same key to all other language files
- Use in code
Frontend example:
// locales/en/common.json
{ "newFeature": { "title": "New Feature" } }Backend example:
// lang/en/messages.php
'new_feature' => ['created' => 'Feature created.'],Adding a New Language
Example: Adding Vietnamese (vi)
Step 1: Backend
# 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/*.phpStep 2: Frontend
# 1. Copy translation files
cp -r frontend/src/i18n/locales/en frontend/src/i18n/locales/vi// 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:
// 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
# 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 testsStep 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.
Update Seeders: Add the new language translations to
PetTypeSeeder.php,CategorySeeder.php, andCitySeeder.php.php// Example in PetTypeSeeder.php ['en' => 'Cat', 'ru' => 'Кошка', 'vi' => 'Mèo']Ensure
updateOrCreate: Seeders should useupdateOrCreateorupdateto ensure existing records get the new translation arrays.Run Seeders:
bashdocker 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
| Language | Forms | Suffixes |
|---|---|---|
| English | 2 | _one, _other |
| Russian | 4 | _one, _few, _many, _other |
| Ukrainian | 4 | _one, _few, _many, _other |
| Vietnamese | 1 | No suffix needed |
Maintenance Commands
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
| Problem | Solution |
|---|---|
| Translation not showing | Check key exists, namespace is correct, file is imported in index.ts |
| Language switcher missing new language | Add language.XX key to ALL existing common.json files |
| Accept-Language header not sent | Check Axios interceptor in src/api/axios.ts |
| User locale not persisting | Verify user is authenticated, check /api/user/locale endpoint |
| Mixed-language dropdown options | Ensure translatable list fetches are locale-scoped and refetched on language change |
Key Files
Backend:
config/locales.php- Supported locales configapp/Http/Middleware/SetLocaleMiddleware.php- Locale detectionapp/Support/TranslatableSql.php- Locale fallback SQL helper for translatable JSONB fieldsapp/Services/Notifications/NotificationLocaleResolver.php- Resolves notification localeslang/{locale}/messages.php- API messageslang/{locale}/validation.php- Validation messages
Frontend:
src/i18n/index.ts- i18next configurationsrc/i18n/locales/{locale}/*.json- Translation filessrc/components/LanguageSwitcher.tsx- Language selectorsrc/hooks/useLocaleSync.ts- Syncs user locale preferencesrc/api/axios.ts- Accept-Language header interceptor