Previous Article
Custom Social Share Buttons Without Third-Party Scripts (2026)
30 May 2018
Adding languages to a CodeIgniter site is mostly straightforward. The one decision that matters — and the one most tutorials get wrong — is where the chosen language lives. Store it in the session and your translated content is invisible to search engines.
This guide uses CodeIgniter 4. CI3 is end-of-life; if you are on it, the concepts transfer but the APIs have all changed.
Beyond SEO, session switching breaks sharing (a link opens in the recipient's language, not yours), breaks page caching (one URL, several possible responses), and breaks the back button.
Put the locale in the URL. CodeIgniter 4 supports this directly.
<?php // app/Config/App.php public string $defaultLocale = 'en'; // Only these are accepted. Anything else is rejected — this list is // the security boundary, not just configuration. public array $supportedLocales = ['en', 'fr', 'de', 'hi']; // Read the browser's Accept-Language header when no locale is given. public bool $negotiateLocale = true;
<?php
// app/Config/Routes.php
// {locale} is a reserved placeholder — CI4 validates it against
// supportedLocales and sets the request locale automatically.
$routes->group('{locale}', static function ($routes) {
$routes->get('/', 'Home::index');
$routes->get('products', 'Products::index');
$routes->get('products/(:num)', 'Products::show/$1');
});
// Bare URLs redirect to the negotiated locale.
$routes->get('/', static function () {
return redirect()->to('/' . service('request')->getLocale());
});
Because {locale} is validated against $supportedLocales, a request for /xx/products 404s rather than reaching your code. That is what closes the file-inclusion risk described below.
CI4 keeps these in app/Language/{locale}/, one PHP file per group, returning an array:
<?php
// app/Language/en/Site.php
return [
'welcome' => 'Welcome to Being Idea',
'subscribe' => 'Subscribe for updates by email',
'greeting' => 'Hello, {0}!',
'itemCount' => '{0, plural, =0{No items} one{# item} other{# items}}',
'lastUpdated' => 'Updated {0, date, long}',
];
<?php
// app/Language/fr/Site.php
return [
'welcome' => 'Bienvenue sur Being Idea',
'subscribe' => 'Abonnez-vous aux mises à jour par e-mail',
'greeting' => 'Bonjour, {0} !',
'itemCount' => '{0, plural, =0{Aucun article} one{# article} other{# articles}}',
'lastUpdated' => 'Mis à jour le {0, date, long}',
];
Use them with the lang() helper:
<h1><?= esc(lang('Site.welcome')) ?></h1>
<p><?= esc(lang('Site.greeting', [$user['name']])) ?></p>
<p><?= esc(lang('Site.itemCount', [$count])) ?></p>
Those {0, plural, ...} forms are ICU MessageFormat, and they matter more than they look. English has two plural forms; Russian has three, Arabic six. Concatenating "1 item" / "N items" by hand cannot express that. ICU lets each translation file declare its own rules — which is also why CI4 requires the intl extension.
Switching now means linking to the same page under a different locale prefix:
<?php
// app/Helpers/locale_helper.php
/**
* Rebuild the current URL with a different locale segment.
* Never redirects to a user-supplied destination.
*/
function switch_locale_url(string $locale): string
{
$request = service('request');
$segments = explode('/', trim($request->getUri()->getPath(), '/'));
$supported = config('App')->supportedLocales;
if (isset($segments[0]) && in_array($segments[0], $supported, true)) {
$segments[0] = $locale; // replace the existing locale
} else {
array_unshift($segments, $locale); // or add one
}
return base_url(implode('/', $segments));
}
<nav class="lang-switcher" aria-label="<?= esc(lang('Site.chooseLanguage')) ?>">
<ul>
<?php foreach (['en' => 'English', 'fr' => 'Français', 'de' => 'Deutsch'] as $code => $label): ?>
<li>
<a href="<?= esc(switch_locale_url($code), 'attr') ?>"
hreflang="<?= esc($code, 'attr') ?>"
lang="<?= esc($code, 'attr') ?>"
<?= $code === service('request')->getLocale() ? 'aria-current="true"' : '' ?>>
<?= esc($label) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</nav>
Three deliberate choices here. These are real links, so they work without JavaScript and can be opened in a new tab — unlike the onchange="window.location.href=..." select in the original, which also requires an unsafe Content Security Policy. Each option carries hreflang and lang so assistive technology announces the language name correctly. And language names appear in their own language — "Français", not "French".
The CI3 approach that circulates for this contains both.
// Vulnerable redirect($_SERVER['HTTP_REFERER']);
The Referer header is supplied by the client. An attacker sends a victim to yoursite.com/LanguagesSwitcher/switchLang/english with a forged referer, and your site bounces them to a phishing page — carrying your domain's credibility into the redirect. Never redirect to an unvalidated user-supplied URL. Building the destination yourself, as above, avoids the question entirely.
// Vulnerable
$this->session->set_userdata('site_lang', $language);
$ci->lang->load('message', $siteLang);
Nothing checks $language against a list of known locales before it is used to build a file path. Values containing ../ become a path traversal attempt. Always validate against an allowlist:
if (! in_array($locale, config('App')->supportedLocales, true)) {
throw PageNotFoundException::forPageNotFound();
}
With CI4's {locale} placeholder this happens for you — another reason to use it rather than rolling your own.
Tell search engines which URLs are translations of one another, or they may treat them as duplicate content:
<?php $path = ltrim(preg_replace('#^/[a-z]{2}#', '', service('request')->getUri()->getPath()), '/'); ?>
<link rel="alternate" hreflang="en" href="<?= base_url('en/' . $path) ?>" />
<link rel="alternate" hreflang="fr" href="<?= base_url('fr/' . $path) ?>" />
<link rel="alternate" hreflang="de" href="<?= base_url('de/' . $path) ?>" />
<link rel="alternate" hreflang="x-default" href="<?= base_url('en/' . $path) ?>" />
Two rules that are easy to get wrong. The tags must be reciprocal — every language must list every other, including itself, or Google ignores them. And x-default should point at whatever you serve to a visitor whose language you do not support.
Set the document language too:
<html lang="<?= esc(service('request')->getLocale(), 'attr') ?>">
Screen readers use this to select the right pronunciation rules. A French page announced with English phonetics is close to unintelligible.
URL-based locales and remembering a choice are not mutually exclusive — just do not let the memory override an explicit URL:
// app/Controllers/BaseController.php
protected function initController(...): void
{
parent::initController(...);
$locale = $this->request->getLocale();
// The URL always wins. Only store it for the next bare visit.
setcookie('preferred_locale', $locale, [
'expires' => time() + 86400 * 365,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
}
Then use that cookie only when someone arrives at the bare root. Never auto-redirect a visitor who explicitly requested /fr/products — including Googlebot, which crawls from a US IP and will simply never see your other languages if you redirect on location.
/fr/products is fine; /fr/produits is better for SEO but needs per-locale route definitions. Add it once the basics work.dir="rtl" on <html> and logical CSS properties (margin-inline-start rather than margin-left).Site.submitButton, not Site.clickHere — the latter becomes meaningless once the text changes.intl, not date() — separators and ordering vary by locale.{locale} placeholder; it validates against $supportedLocales for you.HTTP_REFERER or any unvalidated URL.hreflang tags and set <html lang>.Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.