Skip to content

Lab: Localization & Multi-language Support

In this lab, you’ll implement a complete localization (i18n) system for your e-commerce application using Symfony Translation component. You’ll enable your application to support multiple languages, allowing users to view content in their preferred language.

What is Localization? Localization (also called internationalization or i18n) is the process of adapting your application to support multiple languages and regional settings. Instead of hardcoding text like “Welcome” in your views, you’ll use translation keys like trans('home.welcome') that display different text based on the user’s language preference.

Real-World Applications:

  • E-commerce sites serving multiple countries
  • SaaS applications with global user base
  • Government and educational websites
  • Mobile applications with international users

Symfony Translation is a powerful and flexible component that provides enterprise-grade internationalization (i18n) capabilities. Here are the main features you’ll be using in this lab:

Easily support multiple languages by organizing translations in separate files. Switch between languages seamlessly at runtime.

// English
trans('home.welcome') // → "Welcome to our e-commerce store"
// French (when locale is set to 'fr')
trans('home.welcome') // → "Bienvenue dans notre boutique en ligne"

Insert dynamic values into translations using placeholders, making your translations flexible and reusable.

// Translation: "Hello, %name%! You have %count% new messages."
trans('user.greeting', ['%name%' => 'John', '%count%' => 5])
// → "Hello, John! You have 5 new messages."

If a translation is missing in the requested language, Symfony automatically falls back to your default language, ensuring your application never displays broken text.

// If 'products.new_arrival' doesn't exist in French
trans('products.new_arrival') // → Falls back to English version

Symfony supports various translation file formats. In this lab, we’ll use JSON for its simplicity and readability, but you can also use YAML, PHP arrays, XLIFF, and more.

// Clean, readable JSON format
{
"nav": {
"home": "Home",
"products": "Products"
}
}

Use dot notation to organize translations hierarchically, keeping your translation files clean and maintainable.

trans('errors.404.title') // → "404 - Page Not Found"
trans('errors.404.message') // → "Oops! The page..."
trans('products.add_to_cart') // → "Add to Cart"
trans('common.save') // → "Save"

Built-in support for locale management with easy switching between languages using query parameters, sessions, or custom logic.

// Switch to French
$translator->setLocale('fr');
// Get current locale
$translator->getLocale(); // → "fr"

Organize translations into domains (categories) to separate different parts of your application or share translations across projects.

// In this lab we use the 'messages' domain (default)
trans('home.welcome', [], 'messages')

Symfony Translation uses caching to ensure translations are loaded efficiently, even with large translation files.


  • Install and configure Symfony Translation component
  • Create and organize JSON translation files
  • Build a TranslationHelper service to manage translations
  • Implement LocaleMiddleware for language detection with session persistence
  • Update views to use translation keys instead of hardcoded text
  • Apply XSS protection when displaying translated content
  • Understand fallback mechanisms for missing translations

  • Session Middleware lab completed (SessionManager and SessionMiddleware must be implemented)
  • Flash Messages lab completed
  • Understanding of middleware concept
  • Working e-commerce application with views
  • Basic understanding of JSON format
  • Familiarity with dependency injection

Important: This lab builds on the Session lab. You will use the SessionManager class (with static methods) that you created in the Session lab to persist locale preferences across requests.


Translation Flow:

  1. User visits your application (optionally with language parameter like ?lang=fr)
  2. LocaleMiddleware detects language based on URL parameter, session, or default
  3. Application loads the appropriate translation file (lang/fr/messages.json for French)
  4. Views use trans('home.welcome') instead of hardcoded “Welcome”
  5. TranslationHelper returns “Bienvenue” from French translations
  6. User sees content in French

Language Detection Priority:

  1. URL Parameter - ?lang=fr (user explicitly selects language - highest priority)
  2. Session Value - Previously saved preference (persists across requests)
  3. Default Locale - English (fallback when no parameter or session value)

File Organization:

/lang
/en
messages.json → English translations
/fr
messages.json → French translations

Translation Keys: Use dot notation to organize translations hierarchically:

{
"home": {
"title": "Home",
"welcome": "Welcome to our store"
},
"nav": {
"home": "Home",
"products": "Products"
}
}

Access with: trans('home.welcome') or trans('nav.home')


The following steps will guide you through the process of implementing localization in your application. Please follow the steps in order and complete each step before moving on to the next one.

Step 1: Install Symfony Translation Component

Section titled “Step 1: Install Symfony Translation Component”

Objective: Add Symfony Translation library to your project.

Open your terminal in the project root and run:

Terminal window
composer require symfony/translation

What Gets Installed:

  • symfony/translation - Core translation functionality
  • symfony/translation-contracts - Translation interfaces
  • symfony/polyfill-mbstring - Multi-byte string support
  • symfony/deprecation-contracts - Compatibility layer

Verify Installation: Check that vendor/symfony/translation directory exists and composer.json includes the dependency.


Objective: Set up folders for translation files.

Create the following directory structure in your project root:

Terminal window
mkdir lang
mkdir lang/en
mkdir lang/fr

Result:

/lang
/en (English translations)
/fr (French translations)

Why separate folders? Each language gets its own folder to keep translations organized and make it easy to add new languages later.


Objective: Create JSON files with translations for English and French.

Objective: Create a JSON file with English translations organized by sections.

Create lang/en/messages.json with the following structure:

Understanding Translation Groups:

Translations are organized using dot notation to create logical groups. Each group represents a section of your application:

  • app.* - Application-wide settings (title, name, etc.)
  • nav.* - Navigation menu items
  • home.* - Home page specific text
  • products.* - Product-related text
  • cart.* - Shopping cart text
  • common.* - Reusable text across the app (buttons, actions)
  • errors.* - Error messages grouped by error code

Your Task:

Create the file following this structure. Here’s a minimal example showing how to group translations:

lang/en/messages.json
{
"nav": {
"home": "Home",
"products": "Products",
"cart": "Cart"
},
"home": {
"welcome": "Welcome to our e-commerce store",
"featured_products": "Featured Products"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete"
},
"errors": {
"404": {
"title": "404 - Page Not Found",
"message": "Oops! The page you are looking for does not exist."
}
}
}

Now expand this file by adding:

  • More navigation items (login, logout, etc.)
  • Product-related translations (add_to_cart, price, description, etc.)
  • Cart translations (title, empty, quantity, total, etc.)
  • App-wide translations (title, description, name)
  • More common actions and error messages

Tips:

  • Add translations for ALL pages in your application
  • Keep keys descriptive (e.g., products.add_to_cart not products.btn1)
  • Use nested groups for related errors (e.g., errors.404.title, errors.404.message)
  • Validate your JSON syntax using JSONLint

Objective: Translate the English file to French while keeping the same structure.

Your Task:

  1. Copy the entire structure from your lang/en/messages.json file
  2. Translate ONLY the values to French (keep all keys in English)
  3. Ensure both files have identical structure

Example (translate the values):

lang/fr/messages.json
{
"nav": {
"home": "Accueil",
"products": "Produits",
"cart": "Panier"
},
"home": {
"welcome": "Bienvenue dans notre boutique en ligne",
"featured_products": "Produits en Vedette"
},
"common": {
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer"
},
"errors": {
"404": {
"title": "404 - Page Non Trouvée",
"message": "Oups! La page que vous recherchez n'existe pas."
}
}
}

Translation Hints:

  • Use Google Translate or a French dictionary
  • Common translations: Home → Accueil, Products → Produits, Cart → Panier, Welcome → Bienvenue, Save → Enregistrer, Delete → Supprimer
  • Don’t forget French special characters: é, è, ê, à, ô, ç

Important:

  • Both files must have identical keys (only values differ)
  • Save with UTF-8 encoding to support special characters
  • Validate JSON syntax using JSONLint

Objective: Build a service to manage translations using Symfony Translation component.

The TranslationHelper wraps Symfony’s Translator to provide a clean interface for your application. It loads translation files, manages the current locale, and provides methods to translate text.

Create app/Helpers/TranslationHelper.php:

app/Helpers/TranslationHelper.php
<?php
declare(strict_types=1);
namespace App\Helpers;
use Symfony\Component\Translation\Translator;
use Symfony\Component\Translation\Loader\JsonFileLoader;
/**
* Translation Helper
*
* Provides translation functionality using Symfony Translation component.
* Supports multiple locales with JSON-based translation files.
*/
class TranslationHelper
{
private Translator $translator;
private string $currentLocale;
private string $defaultLocale;
private string $langPath;
private array $availableLocales;
/**
* Initialize the Translation Helper
*
* @param string $langPath Path to language files directory
* @param string $defaultLocale Default locale (fallback)
* @param array $availableLocales List of available locales
*/
public function __construct(
string $langPath,
string $defaultLocale = 'en',
array $availableLocales = ['en', 'fr']
) {
// TODO: Store all constructor parameters in their corresponding class properties
// Hint: You have 4 properties to initialize (langPath, defaultLocale, currentLocale, availableLocales)
// Note: currentLocale should start as the same value as defaultLocale
// TODO: Create a new Translator instance and store it in $this->translator
// Hint: Pass the current locale to the Translator constructor
// TODO: Configure the translator to fall back to the default locale if a translation is missing
// Hint: Use the setFallbackLocales() method with an array containing the default locale
// TODO: Register the JSON file loader with the translator
// Hint: Use addLoader() method with 'json' as the format name
// TODO: Load all translation files
// Hint: Call the loadTranslations() method
}
/**
* Load translation files for all available locales
*/
private function loadTranslations(): void
{
// TODO: Loop through each locale in availableLocales array
// TODO: For each locale, build the file path to messages.json
// Hint: Use DIRECTORY_SEPARATOR constant for cross-platform compatibility
// Example path: lang/en/messages.json
// TODO: Check if the file exists before trying to load it
// TODO: If file exists, register it with the translator using addResource()
// Parameters: format ('json'), file path, locale code, domain ('messages')
}
/**
* Translate a message
*
* @param string $key Translation key (supports dot notation: 'home.welcome')
* @param array $parameters Parameters to replace in translation
* @param string|null $locale Override current locale for this translation
* @return string Translated message
*/
public function trans(string $key, array $parameters = [], ?string $locale = null): string
{
// TODO: If no locale is provided, use the current locale
// Hint: Use the null coalescing operator (??)
// TODO: Use the translator's trans() method to translate the key
// Parameters: key, parameters, domain ('messages'), locale
// Hint: Return the result
}
/**
* Set the current locale
*
* @param string $locale Locale code (e.g., 'en', 'fr')
* @throws \InvalidArgumentException If locale is not available
*/
public function setLocale(string $locale): void
{
// TODO: Validate that the requested locale is available
// Hint: Check if $locale exists in $this->availableLocales array
// If not available, throw an InvalidArgumentException with a descriptive message
// TODO: Update the currentLocale property with the new locale
// TODO: Also update the translator's locale
// Hint: The translator has its own setLocale() method
}
/**
* Get the current locale
*
* @return string Current locale code
*/
public function getLocale(): string
{
// TODO: Return the current locale property
}
/**
* Get the default locale
*
* @return string Default locale code
*/
public function getDefaultLocale(): string
{
// TODO: Return the default locale property
}
/**
* Get all available locales
*
* @return array List of available locale codes
*/
public function getAvailableLocales(): array
{
// TODO: Return the available locales array property
}
/**
* Check if a locale is available
*
* @param string $locale Locale code to check
* @return bool True if locale is available
*/
public function isLocaleAvailable(string $locale): bool
{
// TODO: Check if the given locale exists in the availableLocales array
// Hint: Use in_array() function
}
}

What This Class Does:

  • Constructor: Initializes Symfony Translator, sets up fallback locale, loads all translation files
  • loadTranslations(): Reads JSON files from lang/{locale}/messages.json and registers them
  • trans(): Translates a key (e.g., “home.welcome”) to the current language
  • setLocale(): Changes the active language (validates it’s available first)
  • Getter methods: Provide access to current locale, available locales, etc.

Step 5: Create LocaleMiddleware with Session Persistence

Section titled “Step 5: Create LocaleMiddleware with Session Persistence”

Objective: Build middleware to automatically detect and set the user’s preferred language with session persistence.

LocaleMiddleware runs on every request and determines which language to use based on:

  1. URL Parameter (?lang=fr) - Explicit user choice (highest priority)
  2. Session Value - Previously saved preference
  3. Default Locale - English fallback (lowest priority)

Why Session Persistence?

Without session persistence, users would need to add ?lang=fr to every URL. With session persistence:

  • Language preference is remembered across page navigation
  • Users only need to select their language once
  • Better user experience

Prerequisites:

  • SessionManager must exist from the Session Middleware lab
  • SessionMiddleware must be registered (starts the session)

Create app/Middleware/LocaleMiddleware.php:

app/Middleware/LocaleMiddleware.php
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Helpers\SessionManager;
use App\Helpers\TranslationHelper;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\MiddlewareInterface;
/**
* Locale Middleware
*
* Detects and sets the application locale based on:
* 1. Query parameter (?lang=fr) - Highest priority
* 2. Session value - Previously saved preference
* 3. Default locale - Fallback
*/
class LocaleMiddleware implements MiddlewareInterface
{
private TranslationHelper $translator;
/**
* Initialize the Locale Middleware
*
* @param TranslationHelper $translator Translation helper service
*/
public function __construct(TranslationHelper $translator)
{
// TODO: Store the translator parameter in the class property
}
/**
* Process an incoming server request.
*
* Detects the user's preferred locale from query parameters or session,
* and sets it in the translation helper.
*/
public function process(Request $request, RequestHandler $handler): ResponseInterface
{
// TODO: Get query parameters from the request
// Hint: Use $request->getQueryParams()
// TODO: Extract the 'lang' parameter from query params (if provided)
// Hint: Use null coalescing operator (??) to default to null
// TODO: Determine which locale to use based on priority:
// Priority 1: URL parameter (?lang=fr)
// Priority 2: Session value (retrieve 'locale' key from session using SessionManager)
// Priority 3: Default locale
// Hint: Use the null coalescing operator (??) to chain these three sources
// TODO: If a NEW locale was explicitly requested via URL parameter AND it's valid:
// 1. Set it in the translator
// 2. Save it to the session for future requests
// Hint: Check both that $locale is not null AND that it's available
// Use SessionManager to store the key 'locale' with the locale value in the session
// TODO: If no URL parameter was provided, but session has a saved locale AND it's valid:
// Set the session locale in the translator
// Hint: Use elseif to handle this case
// Use SessionManager to retrieve the 'locale' value from the session
// TODO: Store the current locale in the request as an attribute named 'locale'
// Hint: Use $request->withAttribute() and reassign the result
// TODO: Pass the request to the next middleware/handler and return the response
}
}

Implementation Hints:

The method should follow this logic:

  1. Get the lang parameter from URL query string
  2. If URL parameter provided and valid:
    • Set it in translator
    • Store it in session with key 'locale'
  3. Else if session has a saved locale (key 'locale') and it’s valid:
    • Set it in translator
  4. Store current locale in request attribute
  5. Continue to next middleware

Important Notes:

  • SessionManager uses static methods (recall what you learned in the Session lab)
  • You do NOT need to inject SessionManager into the constructor
  • SessionMiddleware (from the Session lab) starts the session automatically
  • Use the null coalescing operator (??) to chain fallbacks for priority detection

Objective: Register new components in the application’s dependency injection container and middleware stack.

Objective: Define a constant for the language files directory path.

Open config/constants.php and add a new constant:

Your Task:

  • Location: After the APP_VIEWS_PATH constant
  • Constant name: APP_LANG_PATH
  • Value: Combine APP_BASE_DIR_PATH with '/lang'
  • Add a comment explaining the constant’s purpose

Why? Centralizes the path to translation files so you can change it in one place.


Objective: Register TranslationHelper and LocaleMiddleware in the dependency injection container.

Open config/container.php.

Step 1: Add Use Statements

At the top of the file with other use statements, add:

  • use App\Helpers\TranslationHelper;
  • use App\Middleware\LocaleMiddleware;

Step 2: Register TranslationHelper

In the $definitions array, add a definition for TranslationHelper::class:

TranslationHelper::class => function (ContainerInterface $container): TranslationHelper {
return new TranslationHelper(
APP_LANG_PATH, // Path to language files
'en', // Default locale (fallback language)
['en', 'fr'] // Available locales (languages your app supports)
);
},

Step 3: Register LocaleMiddleware

In the $definitions array, add a definition for LocaleMiddleware::class:

LocaleMiddleware::class => function (ContainerInterface $container): LocaleMiddleware {
return new LocaleMiddleware(
$container->get(TranslationHelper::class) // Inject TranslationHelper dependency
);
},

What This Does:

  • Tells the container how to create TranslationHelper with the correct constructor parameters
  • Tells the container how to create LocaleMiddleware and inject TranslationHelper as a dependency
  • Enables automatic dependency injection throughout your application

Objective: Add LocaleMiddleware to the middleware stack in the correct order.

Open config/middleware.php.

Your Task:

  1. Add the use statement at the top with other middleware imports:

    • use App\Middleware\LocaleMiddleware;
  2. Register the middleware by adding this line:

    // Detect and set the application locale (with session persistence)
    $app->add(LocaleMiddleware::class);

Important: Middleware Order

Middleware must be registered in the correct order. SessionMiddleware MUST be added BEFORE LocaleMiddleware:

// Correct order:
$app->add(SessionMiddleware::class); // This must come FIRST
$app->add(LocaleMiddleware::class); // This comes AFTER

Why is order critical?

Middleware in Slim 4 works like an onion - the last one added runs first:

  1. SessionMiddleware must run first to start the session
  2. LocaleMiddleware runs second and can then use SessionManager to work with locale data

What happens if the order is wrong?

  • If LocaleMiddleware runs before SessionMiddleware, the session won’t be started yet
  • Attempting to retrieve or store locale in session would fail or return incorrect values
  • Locale won’t persist across requests

Objective: Create a global helper function trans() for easy translation in views.

Open config/functions.php and add the following function at the end of the file:

config/functions.php
if (!function_exists('trans')) {
/**
* Translate a message using the application's translation helper.
*
* This function provides a convenient way to translate strings in views and controllers.
* It uses the global TranslationHelper instance to perform translations with support
* for nested keys (dot notation) and parameter substitution.
*
* @param string $key Translation key (supports dot notation: 'home.welcome')
* @param array $parameters Parameters to replace in translation (e.g., ['name' => 'John'])
* @param string|null $locale Override current locale for this translation
* @return string Translated message or the key itself if translation not found
*
* @example
* echo trans('home.welcome');
* // Outputs: "Welcome to our e-commerce store" (in current locale)
*
* @example
* echo trans('common.hello', ['name' => 'Alice']);
* // Outputs: "Hello, Alice" (with parameter substitution)
*
* @example
* echo trans('nav.home', [], 'fr');
* // Outputs: "Accueil" (forced French translation)
*/
function trans(string $key, array $parameters = [], ?string $locale = null): string
{
global $translator;
if (!isset($translator)) {
// Fallback: return the key if translator is not initialized
return $key;
}
return $translator->trans($key, $parameters, $locale);
}
}

Why a global function? Makes views cleaner - you can write trans('home.welcome') instead of $translator->trans('home.welcome').


Objective: Make the TranslationHelper instance available globally for the trans() helper function.

Open config/bootstrap.php.

Your Task:

Add the following code AFTER the container is built (after the line $container = $containerBuilder->build();):

config/bootstrap.php
// Set up global translator for use in trans() helper function
global $translator;
$translator = $container->get(\App\Helpers\TranslationHelper::class);

What This Does:

  • Declares a global variable $translator
  • Retrieves the TranslationHelper instance from the container
  • Makes it available to the trans() helper function throughout your application

Objective: Replace hardcoded text in views with translation keys.

Open app/Views/homeView.php (or equivalent).

Before:

<?php
$page_title = 'Home';
ViewHelper::loadHeader($page_title);
?>
<h1>Welcome to Our E-Commerce Store</h1>
<p>Browse our products and enjoy a seamless shopping experience.</p>

After:

<?php
$page_title = trans('home.title');
ViewHelper::loadHeader($page_title);
?>
<h1><?= hs(trans('home.welcome')) ?></h1>
<p><?= hs(trans('home.description')) ?></p>

IMPORTANT: Always use hs() to escape output and prevent XSS attacks!


Before:

<nav>
<a href="/">Home</a>
<a href="/products">Products</a>
<a href="/cart">Cart</a>
</nav>

After:

<nav>
<a href="/"><?= hs(trans('nav.home')) ?></a>
<a href="/products"><?= hs(trans('nav.products')) ?></a>
<a href="/cart"><?= hs(trans('nav.cart')) ?></a>
</nav>

Open app/Views/errors/404.php.

Before:

<?php
$page_title = '404 - Page Not Found';
?>
<h1>Page Not Found</h1>
<p>Sorry, the page you are looking for does not exist.</p>
<a href="/">Go Back Home</a>

After:

<?php
$page_title = trans('errors.404.title');
?>
<h1><?= hs(trans('errors.404.title')) ?></h1>
<p><?= hs(trans('errors.404.message')) ?></p>
<a href="/"><?= hs(trans('errors.404.back_home')) ?></a>

Before:

<button type="submit">Save</button>
<button type="button">Cancel</button>

After:

<button type="submit"><?= hs(trans('common.save')) ?></button>
<button type="button"><?= hs(trans('common.cancel')) ?></button>

Objective: Create a UI component that allows users to easily switch languages.

You can add this to your header (or admin_header.php) or navigation file:

app/Views/common/header.php
<div class="language-switcher">
<?php
// Get current locale from global translator
global $translator;
$currentLocale = $translator->getLocale();
$availableLocales = $translator->getAvailableLocales();
?>
<?php foreach ($availableLocales as $locale): ?>
<?php if ($locale !== $currentLocale): ?>
<a href="?lang=<?= hs($locale) ?>" class="lang-link">
<?= $locale === 'en' ? 'English' : 'Français' ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
<span class="current-lang">
<?= $currentLocale === 'en' ? '🇬🇧 English' : '🇫🇷 Français' ?>
</span>
</div>

Styling Example:

.language-switcher {
display: flex;
gap: 10px;
align-items: center;
}
.lang-link {
padding: 5px 10px;
text-decoration: none;
border: 1px solid #ccc;
border-radius: 4px;
}
.current-lang {
font-weight: bold;
}

  1. Visit http://localhost/[your-app]/
  2. Expected: Page displays in English
  3. Check home page title shows “Welcome to our e-commerce store”
  1. Visit http://localhost/[your-app]/?lang=fr
  2. Expected: Page displays in French
  3. Check home page title shows “Bienvenue dans notre boutique en ligne”

Test Case 3: Session Persistence - Locale Persists Across Pages

Section titled “Test Case 3: Session Persistence - Locale Persists Across Pages”
  1. Visit http://localhost/[your-app]/?lang=fr
  2. Expected: Page displays in French
  3. Click any navigation link (without ?lang=fr)
  4. Expected: New page still displays in French ✓
  5. Why? Locale was saved to session and persists across navigation

Test Case 4: URL Parameter Overrides Session

Section titled “Test Case 4: URL Parameter Overrides Session”
  1. Have French saved in session (from previous test)
  2. Visit http://localhost/[your-app]/?lang=en
  3. Expected: Page displays in English
  4. Navigate without query parameter
  5. Expected: Still shows English (session was updated)

Test Case 5: Session Survives Browser Refresh

Section titled “Test Case 5: Session Survives Browser Refresh”
  1. Select French via ?lang=fr
  2. Refresh the page (without ?lang=fr in URL)
  3. Expected: Page still displays in French
  1. Clear browser cookies/session
  2. Visit http://localhost/[your-app]/ (no query parameter)
  3. Expected: Page displays in English (default locale)
  1. Visit http://localhost/[your-app]/?lang=es (Spanish not supported)
  2. Expected: Falls back to English (default)
  1. Visit multiple pages in French mode
  2. Expected: All pages show French consistently
  3. Navigation, buttons, errors should all be translated
  1. Temporarily use a non-existent key: trans('fake.key')
  2. Expected: Displays “fake.key” (fallback behavior)
  1. Switch to French
  2. Check that special characters display correctly: é, à, è, ç
  3. Expected: No garbled characters or encoding issues
  1. Test with translation values
  2. Expected: Content is HTML-escaped (no script execution)

Symptom: Translation keys are displayed instead of actual text (e.g., “home.welcome” instead of “Welcome…”)

Possible Causes:

  • JSON file has invalid syntax
  • Translation key doesn’t exist in JSON file
  • File not loaded by TranslationHelper

Solution:

  1. Validate JSON using JSONLint
  2. Check that key exists in both lang/en/messages.json and lang/fr/messages.json
  3. Verify loadTranslations() method is called in constructor
  4. Check file permissions (must be readable)

Symptom: Application always displays English even with ?lang=fr

Possible Causes:

  • LocaleMiddleware not registered
  • Middleware registered in wrong order
  • Query parameter not being checked correctly

Solution:

  1. Check config/middleware.php has $app->add(LocaleMiddleware::class);
  2. Ensure LocaleMiddleware runs BEFORE route handlers
  3. Verify process() method checks query parameters correctly

Issue 3: Special Characters Display Incorrectly

Section titled “Issue 3: Special Characters Display Incorrectly”

Symptom: French characters show as � or weird symbols

Possible Causes:

  • JSON files not saved with UTF-8 encoding
  • Missing charset in HTML

Solution:

  1. Save all JSON files with UTF-8 encoding (no BOM)
  2. Ensure views have <meta charset="UTF-8"> in header
  3. Check database charset is utf8mb4 (if storing translations in DB)

Issue 4: Translations Work in Some Views But Not Others

Section titled “Issue 4: Translations Work in Some Views But Not Others”

Symptom: Some pages translated, others show hardcoded text

Possible Cause:

  • Forgot to update those views to use trans()

Solution:

  1. Search codebase for hardcoded text strings
  2. Replace with appropriate translation keys
  3. Add missing keys to JSON files if needed

Symptom: Language changes with ?lang=fr but reverts to default when navigating to other pages

Possible Causes:

  • Forgot to save locale to session in LocaleMiddleware
  • SessionMiddleware not registered or running after LocaleMiddleware
  • Session not started
  • Incorrect middleware order

Solution:

  1. Check that process() method in LocaleMiddleware saves locale to session when URL parameter is provided
  2. Ensure SessionMiddleware is registered BEFORE LocaleMiddleware in config/middleware.php:
    $app->add(SessionMiddleware::class); // First
    $app->add(LocaleMiddleware::class); // Second
  3. Verify SessionMiddleware is calling the start method from SessionManager
  4. Check browser cookies are enabled (use browser dev tools → Application → Cookies)
  5. Test by checking session directly: Add a debug statement in your view to display the locale from session

Vulnerable Code:

<h1><?= trans('home.title') ?></h1> <!-- XSS RISK -->

Secure Code:

<h1><?= hs(trans('home.title')) ?></h1> <!-- SAFE -->

Why? Even though you control translation files, always escape output to prevent injection attacks if translations are ever stored in a database or come from user input.


The setLocale() method already validates that requested locales are in the allowed list:

if (!in_array($locale, $this->availableLocales)) {
throw new \InvalidArgumentException("Locale '{$locale}' is not available.");
}

Why? Prevents attackers from using locale codes to traverse directories or inject malicious values.


Always save translation files and serve pages with UTF-8:

<meta charset="UTF-8">

Why? Prevents encoding issues that could lead to XSS or display problems.


4. Don’t Trust Client-Provided Locales Blindly

Section titled “4. Don’t Trust Client-Provided Locales Blindly”

LocaleMiddleware validates requested locales against the whitelist:

if ($locale && $this->translator->isLocaleAvailable($locale)) {
$this->translator->setLocale($locale);
}

Why? Ensures users can’t request arbitrary locale codes.


All language files should have the same structure:

Good:

// en/messages.json
{ "home": { "title": "Home", "welcome": "Welcome" } }
// fr/messages.json
{ "home": { "title": "Accueil", "welcome": "Bienvenue" } }

Bad:

// en/messages.json
{ "home": { "title": "Home", "welcome": "Welcome" } }
// fr/messages.json
{ "home": { "title": "Accueil" } } // Missing 'welcome' key!

Follow dot notation pattern:

section.subsection.key
Examples:
- home.title
- nav.products
- errors.404.message
- common.save

{
"products": {
"title": "Products",
"add_to_cart": "Add to Cart",
"price": "Price"
},
"cart": {
"title": "Shopping Cart",
"empty": "Your cart is empty"
}
}

Bad:

<button>Save</button>

Good:

<button><?= hs(trans('common.save')) ?></button>

You can use placeholders in translations to insert dynamic content.

In the following example, the placeholders %name% and %count% in the translation will be replaced with the actual values passed in the parameters array.

If you need to include placeholders in translations:

Translation file:

lang/en/messages.json
{
"user": {
"greeting": "Hello, %name%! You have %count% messages."
}
}

Usage:

app/Views/homeView.php
<?= hs(trans('user.greeting', [
'%name%' => $username,
'%count%' => $messageCount
])) ?>