---
title: "Tutorial: Translation with gettext + PHP"
description: "Free tutorial: How to translate your PHP application with gettext."
canonical: "https://www.codeandweb.com/babeledit/tutorials/translation-with-gettext-and-php"
date: "2025-09-12T00:00:00.000Z"
modified: "2025-09-12T00:00:00.000Z"
source: "html"
---

# Tutorial: Translation with gettext + PHP

Andreas Löw

[babeledit](https://www.codeandweb.com/babeledit/tutorials.md),  [tutorial](https://www.codeandweb.com/tags/tutorial),  [php](https://www.codeandweb.com/tags/php),  [gettext](https://www.codeandweb.com/tags/gettext),  [po-files](https://www.codeandweb.com/tags/po-files)

![Tutorial: Translation with gettext + PHP](https://www.codeandweb.com/static/c4a996d553568788ccb30cac89472156/a6312/translation-with-gettext-and-php.png)

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:

*   [Laravel translation tutorial: How to translate your web app](https://www.codeandweb.com/babeledit/tutorials/how-to-translate-your-laravel-project.md).

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

```php
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

```bash
locale -a
```

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

You can install missing locales with

```bash
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:

```php
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:

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

```bash
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.

> **Warning:** `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:

```bash
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](https://www.codeandweb.com/babeledit.md). Download it here:

[Download BabelEdit 5.7.0](https://www.codeandweb.com/babeledit/download/5.7.0/win)

for Windows

[Also available for macOS and Linux](https://www.codeandweb.com/babeledit/download.md)

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](https://www.codeandweb.com/static/0681b1c418a57fdb0776f3e352f6248d/283d3/configuring-a-po-file-project%402x.png)

*   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](https://www.codeandweb.com/static/7876706241157aaa2ee29a3ac4fabcdb/283d3/editing-po-files-with-babeledit%402x.png)

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

```bash
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:

```bash
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 ...

```bash
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](https://www.codeandweb.com/static/5a6c5ba90cf1bde83c751999a101fb35/283d3/fuzzy-flags-in-po-files%402x.png)

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:

```php
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](https://www.codeandweb.com/static/1078fd5a1b3477625f326aa04d4f99a3/283d3/pluralization-with-po-files%402x.png)

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:

```php
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

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

## Structured data

```json
{
  "@context": "https://schema.org",
  "@type": "TechArticle",
  "headline": "Tutorial: Translation with gettext + PHP",
  "author": [
    {
      "@type": "Person",
      "name": "Andreas Löw"
    }
  ],
  "dateModified": "2025-09-12T00:00:00.000Z",
  "image": "/static/c4a996d553568788ccb30cac89472156/a6312/translation-with-gettext-and-php.png"
}
```
