Tutorial: Translation with gettext + PHP

Andreas Löw
Last updated:
Tutorial: Translation with gettext + PHP

Gettext is the standard way to localize plain PHP applications. In this hands-on tutorial, you’ll learn how to:

  • Prepare your PHP app for translations
  • Extract messages with xgettext
  • Translate PO files quickly with BabelEdit
  • Compile MO files
  • Handle updates, pluralization, and caching without headaches

Using a PHP framework? Many ship their own i18n tooling. If that’s you, check out this guide instead:

The translation workflow

Here’s the big picture of the gettext workflow for PHP. You’ll cycle through these steps during development:

  1. Extract translatable messages with xgettext → Portable Object Template (.pot)
  2. Create or update language files with msginit/msgmerge → Portable Object (.po)
  3. Compile PO files to fast, binary catalogs with msgfmt → Machine Object (.mo)

Prepare your PHP app for translations

Before you start, make sure gettext will work on both your development and production machines:

  • PHP extensions gettext and mbstring are installed and enabled
  • The locales you plan to support are installed on the OS

The next 2 sections show you how to verify both requirements.

Check PHP modules

PHP needs the gettext and mbstring extensions for translations. Create a tiny script with phpinfo() to confirm they’re enabled. If one is missing, install/enable it in your php.ini (or via your package manager/container image).

Set the language

Start by setting the locale you want to display — and do it before your first translation lookup. Gettext resolves translations at runtime, so the locale must be set first.

setlocale(LC_ALL, 'de_DE.UTF-8');

The gettext module in PHP only supports languages and language identifiers that are installed on your server.

Check the available locales by calling

locale -a

on the command line of your server (e.g., by logging in with SSH).

You can install missing locales with

locale-gen <language>

This command may require admin privileges and might not be available on shared hosting. If your target locale isn’t installed, ask your admin to add it or consider using a framework-level i18n solution.

Set the translation files

Tell PHP where to find your translations and which encoding to use:

bindtextdomain("myapp", "locale");
bind_textdomain_codeset("myapp", "UTF-8");
  • myapp is the text domain (it also becomes the translation file name). You can bind multiple domains if you split translations across files.
  • locale is the base directory that contains your language folders. The path is relative to the PHP file unless you use an absolute path.
  • bind_textdomain_codeset("myapp", "UTF-8") makes sure gettext reads and returns UTF‑8 strings.

In our example PHP will try to load the following translation file: locale/de_DE.UTF-8/LC_MESSAGES/myapp.mo

LC_MESSAGES is a predefined folder name, it is mandatory.

de_DE.UTF-8 is the locale identifier you've passed to setlocale() before. In the folder name, country code, and the encoding are optional. The file loader will also try to find these translation files:

  • locale/de_DE/LC_MESSAGES/myapp.mo
  • locale/de/LC_MESSAGES/myapp.mo

Set the active text domain

Choose which domain gettext should use for subsequent _() lookups:

textdomain("myapp");

The domain name must have been bound with bindtextdomain() first (see above).

Add text markup

Now wrap translatable strings with the _() helper (an alias of gettext). It returns the translation for the locale you set via setlocale():

<?php
$language = 'fr';
putenv("LANG=$language");
setlocale(LC_ALL, $language);

$domain = 'myapp';
bindtextdomain($domain, "./locale");
bind_textdomain_codeset("myapp", "UTF-8");
textdomain($domain);

echo _("Hello World")."\n";

// This comment is extracted and displayed in BabelEdit
echo _("How are you?")."\n";echo _("How are you?")."\n";

The _() function is also used to extract translatable strings from your source code automatically.

A comment above the _() call is extracted by xgettext and displayed in BabelEdit. Use it to give translators context and useful hints.

On the command line, you can use putenv() to set the LANG environment variable. In web apps, setlocale() is usually enough. Environments vary across OS/PHP versions, so using both during development is a safe, portable choice.

Create and manage translation files

Extract strings with xgettext

Use xgettext to scan your PHP files and extract every _() and ngettext() string. It writes a Portable Object Template (.pot) file. Don’t hand-edit the POT file — xgettext regenerates it each time.

xgettext --from-code UTF-8 --add-comments *.php -o myapp.pot

With the --add-comments option, comment blocks preceding the _(...) expression are copied from the source code to the .pot file. Use these comments to give translators helpful context.

xgettext will set a placeholder for the charset (CHARSET), even if you set it to UTF-8.
Search for Content-Type: text/plain; charset=CHARSET and replace CHARSET with UTF-8.

Create initial PO files with msginit

The POT file is the template file used to create a new PO file for each target language. Place them in directories as mentioned above:

mkdir locale locale/de locale/fr locale/de/LC_MESSAGES locale/fr/LC_MESSAGES
msginit --locale de.UTF-8 --input myapp.pot --output locale/de/LC_MESSAGES/myapp.po
msginit --locale fr.UTF-8 --input myapp.pot --output locale/fr/LC_MESSAGES/myapp.po

This step is only needed once when you set up your project. As msginit overwrites an existing PO file, you shouldn't call it if you have already PO files containing translations. In this case use msgmerge as described later.

Translate the PO files

The easiest way to translate PO files is with BabelEdit. Download it here:

Then:

  • Drag and drop your locale folder into BabelEdit (it will detect your PO files).
  • Confirm the auto-detected languages (you can adjust them if needed), then click OK.
Configuring a po file project in BabelEdit
  • Select all items on the left and click Pre Translate in the toolbar.
  • Keep the default preset and click Translate to fill in French and German automatically.
Editing po files with BabelEdit
Editing po files with BabelEdit
  • Review and tweak the suggestions where necessary.
  • Click Save. BabelEdit will ask you for a project file name — it stores your language/file setup so you can reopen everything later with one click.

Convert PO to MO files using msgfmt

To use the translated strings in your PHP script, you have to convert the PO files into "Machine Object" files (with the extension .mo).

msgfmt locale/de/LC_MESSAGES/myapp.po --output-file=locale/de/LC_MESSAGES/myapp.mo
msgfmt locale/fr/LC_MESSAGES/myapp.po --output-file=locale/fr/LC_MESSAGES/myapp.mo

Test the app

Run your PHP script and you should see translated output. Heads-up: gettext caches translations. If you add, remove, or update MO files, you may need to reload/restart your web server to see the changes.

We offer a solution to this without restarting the server later in this tutorial!

Managing changes with msgmerge

When your PHP code changes, your translations often need to keep up. Use msgmerge — the gettext tool that syncs your template and language files — to update your PO/MO files.

First, you have to extract the translatable strings from the new PHP sources using xgettext. This overwrites your old POT file with a new one.

Then you can merge the changes into the language-specific PO files:

xgettext --add-comments *.php -o myapp.pot
msgmerge --update locale/de/LC_MESSAGES/myapp.po myapp.pot
msgmerge --update locale/fr/LC_MESSAGES/myapp.po myapp.pot

... translate new / changed texts in myapp.po ...

msgfmt locale/de/LC_MESSAGES/myapp.po --output-file=locale/de/LC_MESSAGES/myapp.mo

msgmerge updates your PO files with any new or changed messages. For minor edits, it keeps the match and marks the entry as "fuzzy" — a reminder to review the translation.

Example: If you change "Hello World" to "Hello World!" (with the added !) you will see this in BabelEdit:

Fuzzy flags in PO files
BabelEdit shows a warning symbol for translations in which the source text changed.

Click on the warning symbol to clear it.

Bigger changes in a source message are treated as new messages.

Pluralization

Pluralization selects the correct translation for a given number. This is supported by the ngettext() function:

for ($i = 0; $i <= 3; $i++)
{
    $msg = ngettext(
        "You have one new message",
        "You have %d new messages",
        $i
    );
    printf($msg. "\n", $i) ;
}

The first argument is the singular form, the second is the plural form, and the third is the number. The function returns the correct form for that number — but it doesn’t insert the number for you. Format it yourself with printf() or sprintf().

In BabelEdit, pluralization looks like this:

Pluralization in PO files
Pluralization in PO files

Practical tips for using gettext with PHP

I updated my PO and MO files, but why do I see no changes in the browser?

On servers, gettext can be confusing at first because it caches translations in memory. That cache persists across requests and doesn’t notice file changes — so you may see outdated strings.

Clearing your browser cache won’t help (the cache is on the server). There’s no perfect fix, but this helper function works very well in practice:

function setLocaleWithCacheBusting($locale): void
{
    $domain_base = 'myapp';

    $locale_path = __DIR__ . "/locale/$locale/LC_MESSAGES/";
    $mo_file = $locale_path . $domain_base . '.mo';

    if (file_exists($mo_file))
    {
        $mtime = filemtime($mo_file);
        $domain = $domain_base . '_' . $mtime;
        $moLink = $locale_path . $domain . '.mo';
        if(!file_exists( $moLink ))
        {
            symlink($mo_file, $locale_path . $domain . '.mo');
        }
    }
    else
    {
        $domain = $domain_base;
    }

    bindtextdomain($domain, __DIR__ . "/locale");
    bind_textdomain_codeset($domain, 'UTF-8');

    setlocale(LC_ALL, $locale);

    textdomain($domain);
}

In short, it checks the MO file’s modification time and creates a versioned symlink. That way gettext loads a “new” domain whenever the file changes, busting the cache.

Simplifying extraction and compilation

This Makefile makes extracting and compiling PO files easy on Linux, macOS, and Windows (via WSL).

Adjust the variables at the top:

DOMAIN = myapp
LANGUAGES = de fr

To extract translatable strings from PHP files, run:

make extract

This also updates the PO files with new messages or creates new ones when adding a language.

To compile PO files into MO files, run:

make compile

Here's the complete Makefile:

Makefile
# Variables
DOMAIN = myapp
LANGUAGES = de fr

POTFILE = $(DOMAIN).pot
PHPFILES = phpfilelist.txt

# Show help
help:
	@echo "Available targets:"
	@echo "  extract     - Extract translations to $(POTFILE) and update the PO files"
	@echo "  compile     - Compile PO files to MO files"
	@echo "  help        - Show this help message"
	@echo " "
	@echo "Workflow:"
	@echo " - run 'make extract' to update the PO files with new IDs"
	@echo " - edit the PO files"
	@echo " - run 'make compile' to compile the PO files into MO files"

.PHONY: all extract update compile clean help

extract: extract-pot update-po

# 1) Extract messages and add them to the myapp.pot
extract-pot:
	@echo "Extracting translatable strings from PHP files..."
	find . -name '*.php' >$(PHPFILES)
	@xgettext --language=PHP \
		--keyword=_ \
		--keyword=ngettext:1,2 \
		--from-code=UTF-8 \
        --add-comments \
		--output=$(POTFILE) \
		--package-name=$(DOMAIN) \
		--package-version=1.0 \
		--msgid-bugs-address=dev@example.com \
		--files-from=$(PHPFILES)
	@sed -i.bak 's/charset=CHARSET/charset=UTF-8/' $(POTFILE)
	@rm -f $(POTFILE).bak
	@echo "Messages extracted to $(POTFILE)"

# 2) Update the po files for all languages
update-po:
	@echo "Updating PO files with new messages..."
	@for lang in $(LANGUAGES); do \
		po=locale/$$lang/LC_MESSAGES/$(DOMAIN).po; \
		echo "Updating $$po..."; \
		if [ -f "$$po" ]; then \
			msgmerge --update --backup=off $$po $(POTFILE); \
		else \
			echo "Warning: $$po does not exist, creating from $(POTFILE)"; \
			mkdir -p $$(dirname $$po); \
			msginit --input=$(POTFILE) \
				--output-file=$$po \
				--locale="$$lang.UTF-8"; \
		fi; \
	done
	@echo "PO files updated"

# 3) Compile the po files into mo files
compile:
	@echo "Compiling PO files to MO files..."
	@for lang in $(LANGUAGES); do \
		po=locale/$$lang/LC_MESSAGES/$(DOMAIN).po; \
		mo=locale/$$lang/LC_MESSAGES/$(DOMAIN).mo; \
		find locale/$$lang/LC_MESSAGES/ -name "$(DOMAIN)_*.mo" -type link -exec rm {} +; \
		echo "Compiling $$po -> $$mo"; \
		msgfmt --output-file=$$mo $$po; \
	done
	@echo "MO files compiled"