Internationalization (i18n)

Piko provides internationalization support through a dedicated, framework-agnostic package that can also be used in standalone PHP projects:

composer require piko/i18n

This installs the Piko\I18n component, which loads translations from PHP files and exposes a simple API for translating strings.


Translation files

Translations are stored in plain PHP files that return an array of key–value pairs:

Example directory structure:

app/
├── messages/
│   ├── en.php
│   └── fr.php

Example of a translation file (fr.php):

<?php

return [
    'Translation test' => 'Test de traduction',
    'Hello {name}'     => 'Bonjour {name}',
];

Piko\I18n will look for files named <language>.php inside the directory configured for each domain (see below).


Using I18n in a standalone PHP application

The piko/i18n package can be used without the rest of the Piko framework. At minimum you need:

Example project structure:

project-root/
├── messages/
│   ├── en.php
│   └── fr.php
├── composer.json
└── index.php

messages/fr.php:

<?php

return [
    'Translation test' => 'Test de traduction',
    'Hello {name}'     => 'Bonjour {name}',
];

index.php:

<?php

require __DIR__ . '/vendor/autoload.php';

use Piko\I18n;
use function Piko\I18n\__;

// Create the I18n instance
$i18n = new I18n([
    // domain => directory containing <language>.php files
    'app' => __DIR__ . '/messages',
], 'fr'); // default language is "fr"

// Optional: register this instance for the __() helper
I18n::setInstance($i18n);

// Direct usage
echo $i18n->translate('app', 'Hello {name}', ['name' => 'John']) . "\n";
// Outputs: Bonjour John

// Using the helper
echo __('app', 'Translation test') . "\n";
// Outputs: Test de traduction

You can omit I18n::setInstance() if you do not plan to use the __() helper and only call $i18n->translate() directly.


Registering the I18n component in a Piko application

Declare the component in the application configuration. The key of the components array must match the fully-qualified class name used when retrieving the component:

<?php

return [
    // ...
    'components' => [
        Piko\I18n::class => [
            // Domain => path (can use aliases such as @app)
            'translations' => [
                'app' => '@app/messages',
                // 'user' => '@app/messages/user',
            ],

            // Optional: default language (defaults to 'en')
            // 'language' => 'fr',
        ],
    ],
];

Notes:


Basic usage

Access the I18n component from your application and call translate():

<?php

/** @var Piko\Application $app */

$i18n = $app->getComponent(Piko\I18n::class);

// Simple translation
echo $i18n->translate('app', 'Translation test') . '<br>'; // "Test de traduction" (if language = 'fr')

// Translation with parameters
echo $i18n->translate('app', 'Hello {name}', ['name' => 'John']) . '<br>'; // "Bonjour John"

Behavior:


Using the __() helper function

The package provides a global helper function Piko\I18n\__() as a proxy to I18n::translate():

use function Piko\I18n\__;

Before using __(), you must register the active I18n instance so the helper knows which component to use. Otherwise, calling __() will throw a RuntimeException.

A typical bootstrap/entry script looks like this:

<?php

use Piko\ModularApplication;
use Piko\I18n;
use function Piko\I18n\__;

require __DIR__ . '/../vendor/autoload.php';

$config = require __DIR__ . '/../config/app.php';

$app = new ModularApplication($config);

// Register the I18n instance for the __() helper
$I18n = $app->getComponent(I18n::class);
I18n::setInstance($I18n);

// Now you can safely use __()
echo __('app', 'Translation test') . '<br>';
// Test de traduction

echo __('app', 'Hello {name}', ['name' => 'John']) . '<br>';
// Bonjour John

$app->run();

The helper behaves exactly like I18n::translate():

__('app', 'Hello {name}', ['name' => 'John']);
// is equivalent to
$I18n->translate('app', 'Hello {name}', ['name' => 'John']);

For details, see the Piko\I18n\__ API documentation.


Dynamic language selection in a Piko application

Multi-language applications typically need to switch the language dynamically based on the current request.

Piko\I18n does not impose a specific strategy, but it is designed to be used from middleware. A common approach is:

  1. Read the language code from the first segment of the request URI (e.g. /fr/blog/...).
  2. Strip that segment from the URI so routing continues to work as usual.
  3. Update the I18n component’s language property.
  4. Prefix all generated URLs with the active language so links stay consistent.

Example I18nMiddleware

The middleware below implements this strategy and integrates with the router’s AfterUriBuiltEvent to ensure generated URLs are prefixed with the current language:

<?php

namespace app\lib;

use Piko\Application;
use Piko\I18n;
use Piko\Router;
use Piko\Router\AfterUriBuiltEvent;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

final class I18nMiddleware implements MiddlewareInterface
{
    /** @var I18n */
    private $i18n;

    public function __construct(Application $app)
    {
        $router = $app->getComponent(Router::class);
        assert($router instanceof Router);

        $i18n = $app->getComponent(I18n::class);
        assert($i18n instanceof I18n);

        // Prefix all generated URLs with the current language
        $router->on(
            AfterUriBuiltEvent::class,
            fn (AfterUriBuiltEvent $event) => $event->uri = '/' . $i18n->language . $event->uri
        );

        $this->i18n = $i18n;
    }

    /**
     * {@inheritDoc}
     */
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $uri = $request->getUri();
        $uriPath = $uri->getPath();

        /*
         * Extract the language from the URI path.
         * Example: with "/fr/blog" it will extract "fr",
         * set the I18n language to "fr" and rewrite the path to "/blog".
         */
        if (preg_match('/^\/([a-z]{2})\//', $uriPath, $match)) {
            $lang = $match[1];
            $uriPath = preg_replace('/^\/' . $lang . '\//', '/', $uriPath);

            // Update the request URI so routing ignores the language prefix
            $request = $request->withUri($uri->withPath($uriPath));

            // Update the I18n language so subsequent translations use this locale
            $this->i18n->language = $lang;
        }

        return $handler->handle($request);
    }
}

Wiring the middleware

Register the middleware in the entry script so it runs for every request:

<?php

use Piko\ModularApplication;
use app\lib\I18nMiddleware;

require __DIR__ . '/../vendor/autoload.php';

$config = require __DIR__ . '/../config/app.php';

$app = new ModularApplication($config);

$app->pipe(new I18nMiddleware($app));
$app->run();

With this setup: