Skip to main content
Being Idea Innovations
Multi-Language Sites in CodeIgniter 4: URL Locales, Not Session Switching
Back to Blog

Multi-Language Sites in CodeIgniter 4: URL Locales, Not Session Switching

2 June 20186 min readCodeIgniter
Share:

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.

Why session-based switching is the wrong default

Session-based language switching serves different content at one URL; URL-based locales give each language its own indexable address Session-based — one URL, variable content /products English (if session says so) French (if session says so) German (if session says so) Google indexes ONE version. The others are unreachable. URL-based — one address per language /en/products /fr/products /de/products Indexed, shareable, cacheable Linked to the others via hreflang Works without cookies
A session-based switcher means every language shares one URL. Googlebot holds no session, so it only ever sees the default — the translations may as well not exist.

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.

Configure supported locales

<?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;

Routes with a locale segment

<?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.

Translation files

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.

The language switcher

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".

Two security bugs worth naming

The CI3 approach that circulates for this contains both.

Open redirect

// 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.

Unvalidated locale reaching the filesystem

// 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.

hreflang tags

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.

Remembering a preference

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.

Practical notes

  • Do not translate slugs at first. /fr/products is fine; /fr/produits is better for SEO but needs per-locale route definitions. Add it once the basics work.
  • Right-to-left languages need dir="rtl" on <html> and logical CSS properties (margin-inline-start rather than margin-left).
  • Keep translation keys semantic. Site.submitButton, not Site.clickHere — the latter becomes meaningless once the text changes.
  • Never concatenate translated fragments. Word order differs between languages; use placeholders instead.
  • Format dates and numbers with intl, not date() — separators and ordering vary by locale.

Summary

  • Put the locale in the URL, not the session — otherwise search engines see one language.
  • Use CI4's {locale} placeholder; it validates against $supportedLocales for you.
  • Never redirect to HTTP_REFERER or any unvalidated URL.
  • Always allowlist a locale before it touches a file path.
  • Use ICU MessageFormat for plurals and placeholders.
  • Add reciprocal hreflang tags and set <html lang>.
  • Make the switcher real links, not an inline-JS select.
Share:

Written by

Amit Verma

Founder / 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 Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers