The Dockerfile said fr_FR.UTF-8. PHP saw C.UTF-8.
The Dockerfile describes what the container is supposed to have. It doesnât prove what the application actually uses.
disponible aussi en Français.
A locale bug is deceptive in one particular way: you always think you know the serverâs configuration (after all, you wrote it yourself, in a version-controlled Dockerfile). The trap is conflating what the container declares with what the application process actually uses.
This post walks through how that gap surfaced, and how a fix was then verified directly inside an ECS Fargate task running in staging, via ECS Exec, with no dedicated deployment and no separate staging rig needed. The mechanics of ECS Exec itself (finding the task, IAM prerequisites, quoting) are covered separately in a dedicated article (in French). Here, the focus is on what the diagnostic revealed.
The bug
A string-normalization function, used to deduplicate entries before a Doctrine flush(), stripped accents using the classic method:
iconv('UTF-8', 'ASCII//TRANSLIT', $string);
//TRANSLIT is one of the behavioral extensions offered by iconv implementations (a GNU extension, absent from the POSIX standard). On Linux, the result depends notably on the implementation in use and on the executing processâs LC_CTYPE locale: a glibc bug report documents exactly this case, with the same iconv -f utf-8 -t ascii//TRANSLIT call succeeding under en_US.utf8 and failing under C.UTF-8. Under a strict C locale, transliteration fails silently: "MatĂ©riel" becomes "Mat?riel" instead of "Materiel".
So the deduplication key computed on the PHP side was meant to match MySQLâs ci collation, case- and accent-insensitive. Under the C locale, it didnât. A duplicate would slip past application-level deduplication and eventually hit a database uniqueness constraint at flush() time (a crash downstream of a normalization problem upstream).
The fix that was adopted replaces iconv with Symfonyâs String component:
use function Symfony\Component\String\u;
u($string)->ascii()->toString();
u()->ascii() performs the transliteration at the Symfony component level, without depending on PHPâs LC_CTYPE locale for this step. The component also exposes a dedicated AsciiSlugger for generating slugs (URLs, identifiers); here the need is a deduplication key, not a slug, so u()->ascii() alone is enough, without the casing and separator rules a slugger would add. That said, transliteration rules arenât guaranteed to match iconv //TRANSLITâs exactly: itâs a different library, with its own Unicode tables.
The process locale could also have been initialized with setlocale(LC_ALL, ''). Thatâs not the choice made here: the fix removes the dependency on LC_CTYPE instead of configuring it elsewhere.
âJust read the Dockerfile, right?â
Before touching AWS at all: the repo has a version-controlled CI/CD pipeline (.github/workflows/ci.yml, .docker/Dockerfile, an ECS task definition .aws/pro.json). Canât we just read these files to know the serverâs configuration, without running anything on it?
From static analysis:
-
.github/workflows/ci.yml: theon: push: branches: ["main"]trigger deploys to theproECS service (production); thedevelopbranch deploys topro-rct(staging). Same Dockerfile, same image: only the target ECS service changes. -
.docker/Dockerfilesets a full French locale in anENVinstruction:ENV LANG="fr_FR.UTF-8" \ LANGUAGE="fr_FR:fr" \ LC_ALL="fr_FR.UTF-8" \ ... RUN ... && echo "fr_FR.UTF-8 UTF-8" > /etc/locale.gen && locale-gen fr_FR.UTF-8 ...and installs the
ext-intlextension. -
The ECS task definition contains no override of
LANG/LC_ALL/LC_CTYPEfor thephpcontainer. Nothing at the ECS level contradicts what the Dockerfile sets.
The containerâs OS environment is therefore configured as fr_FR.UTF-8, with ext-intl available.
A first live test inside the container (method detailed in the ECS Exec article, in French) measured, from a PHP script, the processâs current LC_CTYPE locale. Compared against the environment variables at each layer, the problem becomes visible:
Dockerfile LC_ALL=fr_FR.UTF-8
â
âŒ
PID 1 (entrypoint) LC_ALL=fr_FR.UTF-8
â
âŒ
ECS Exec session LC_ALL=fr_FR.UTF-8
â
âŒ
PHP (LC_CTYPE) C.UTF-8 â break
The environment variable is correctly propagated at every layer. But PHP never initialized its LC_CTYPE locale from it.
PHP starts with its locale categories in their initial state. The LANG, LC_ALL, etc. variables present in the environment donât, by themselves, mean the PHP processâs LC_CTYPE was configured with those values. A call to setlocale(LC_ALL, '') explicitly asks the process to sync its locale with the environment.
The value observed here, C.UTF-8, isnât the bare C locale either: itâs a UTF-8 variant of the POSIX locale. It remains distinct from the ICU locale used by ext-intl, which resolves its own default locale independently of LC_CTYPE. iconv(), on the other hand, depends directly on the process locale set by the libc.
A grep -r setlocale across the application code found only two calls, both scoped to LC_TIME (date formatting), never LC_CTYPE, never a global setlocale(LC_ALL, ''). So this really was a missing LC_CTYPE initialization, not a hidden override somewhere in the application.
Static analysis lets you verify the declared configuration. But it doesnât yet tell you what PHP actually uses.
For that, you have to look inside the container.
A local container would have let me reproduce the behavior, provided I rebuilt the exact same image and environment. But the bug was happening in staging: I wanted to first check what the already-deployed PHP was actually doing.
Verifying the fix inside the real container
The verification script loaded the applicationâs Composer autoloader (require '/var/www/vendor/autoload.php';) and then called the private method under test via ReflectionMethod, exactly what a PHPUnit unit test would do (the same technique, run against the real deployment instead of the test suite).
aws ecs execute-command \
--cluster <cluster-name> --task <task-id> --container php --region <REGION> \
--interactive \
--command "/bin/sh -lc 'echo $B64 | base64 -d | php'"
The script travels as base64 rather than as a raw argument. The command crosses three successive shell layers (the local shell, the --command string, then /bin/sh -c inside the container), and a multi-line PHP script with quotes and accented characters always ends up breaking the nested quoting. A $(... || ...) had failed this way with Syntax error: "(" unexpected, not because of a shell logic error but because of the stacked quoting layers. Routing through base64 removes the problem entirely.
Result: the fix holds
normalizeString("Matériel") under current locale: materiel
normalizeString("Matériel") forced under LC_CTYPE=C: materiel
raw iconv("Matériel") under LC_CTYPE=C (old, pre-fix behaviour): 'Mat?riel'
NOTE
The issue observed in staging is
C.UTF-8. To reproduce the oldiconvâs failing behavior, the test forcesLC_CTYPE=C, the strictest case, which reproduces the documented defect.
You can see that the old raw-iconv behavior does reproduce the bug under LC_CTYPE=C, and that u()->ascii() normalizes correctly, with or without a forced locale.
The code no longer depends on LC_CTYPE. A change in the process locale wonât reintroduce this bug.
Locking in the fix: a regression test
The test inside the container verifies the fix on the real deployment. It doesnât protect against a future regression.
The project has no functional tests with an HTTP kernel. Thatâs not a problem here: forcing the locale needs neither a database nor a container, just setlocale().
final class StringNormalizerTest extends TestCase
{
public function testAsciiNormalizationIsLocaleIndependent(): void
{
$original = setlocale(LC_CTYPE, '0');
try {
setlocale(LC_CTYPE, 'C');
self::assertSame(
'Materiel',
StringNormalizer::normalize('Matériel')
);
} finally {
setlocale(LC_CTYPE, $original);
}
}
}
The finally block restores the original locale after the test, so it doesnât pollute the rest of the suite. This test would have caught the bug before it reached production, because it reproduces the exact condition that triggered it: LC_CTYPE=C.
Why this was safe
Running a command inside a staging or production container calls for a few precautions.
In this specific case:
- the diagnostic was read-only;
- the script ran as CLI, in a process isolated from the worker pool serving live traffic (Apache/mod_php): no in-flight request was affected;
- it touched neither the filesystem, nor the database, nor anything shared;
setlocale()called in this one-off process only affects that process.
One caveat: ECS Exec runs with root privileges, which is why the technique should stay a one-off, read-only diagnostic, not a routine practice.
This conclusion only holds if ECS Exec was already enabled on the task. Turning it on, if it isnât, forces a full redeployment of the service. That may have consequences this diagnostic doesnât justify.
Conclusion
So the real subject wasnât ECS Exec, but the gap between a declared configuration and what the application actually uses.
Here, the locale variable really was present in the container. PHP simply wasnât using it for LC_CTYPE.
A few commands inside the container were enough to see it. The regression test now makes sure the code no longer depends on it.
Configuration says what you want. Runtime says what happens.