The Case Of The Insensitive Filesystem
There is a special kind of bug that only exists between your laptop and the production server. It compiles, it tests, it ships, and then it 500s. Somewhere along the way, User.php became user.php, and one of the two machines had an opinion about it.
PHP has no opinion. PHP asks the filesystem, and the filesystem answers according to its own convictions.
Three filesystems, three philosophies
macOS ships APFS in case-insensitive mode. Windows ships NTFS, also case-insensitive. Both are case-preserving: they remember that you wrote Invoice.pdf, they simply refuse to distinguish it from invoice.pdf. Linux, where your code will actually run, ships ext4 or xfs, which are case-sensitive and proud of it.
So the development machine is permissive, and the production machine is not. This is the wrong way around. We spend a lot of effort making staging look like production, and then we write the code on a filesystem that forgives everything.
Action at a distance: new X
The most expensive version of this bug has no filename in it at all.
<?php $object = new Order(); ?>
Nothing here mentions a file. Yet PSR-4 will turn App\Model\Order into src/Model/Order.php, and if the file on disk is order.php, Linux says no. The mismatch lives in two different files, written months apart, possibly by two different people, neither of whom typed anything wrong.
It gets better. PHP class names are case-insensitive: new Order() and new order() are the same class to the engine. But the autoloader receives the class name as it was spelled at the call site. So new order() asks for order.php, new Order() asks for Order.php, and on a case-insensitive filesystem both succeed while quietly registering as the same class. Move to Linux and one of those two lines dies. Which one depends on how the file was named on the day it was created.
PHP is case-insensitive about classes, case-sensitive about variables, and case-agnostic about files. The filesystem breaks the tie, and it does not ask you first.
There is a build-time answer:
composer dump-autoload --optimize --strict-psr--strict-psr fails when a class does not match its path. Put it in CI, not in your notes. And ship the optimized classmap to production, so the runtime stops guessing filenames entirely.
PHP compares bytes. Filenames are Unicode.
This is where the bug stops being a nuisance and becomes interesting.
Filenames are byte strings. Modern filenames are byte strings that happen to hold UTF-8. PHP’s string functions, however, operate on bytes, and since PHP 8.2, strtolower() is explicitly ASCII-only and locale-independent. Which means:
<?php
strtolower('CRÉÉ.JPG'); // 'crÉÉ.jpg' — the accents are untouched
mb_strtolower('CRÉÉ.JPG'); // 'créé.jpg'
?>
Every security check written with strtolower() on a filename has a Unicode-shaped hole in it. If your allowlist compares against 'jpg', fine. If it compares against anything with a diacritic — a directory named Documents Privés, a category slug, a French client’s folder — the comparison is byte-level and the case-insensitive filesystem is not. The filesystem folds É and é together; your PHP code does not. That is a gap, and gaps get walked through.
So: mb_strtolower(), everywhere you would have written strtolower() on a path. Also mb_strlen()for length limits, because a 255-byte filesystem limit is not 255 characters, and créé.jpg is 8 characters and 10 bytes.
Then there is the second Unicode problem, which is worse because it is invisible. HFS+ stores filenames decomposed (NFD): é is e followed by a combining acute accent. Linux and virtually every other tool produce composed form (NFC): é is one code point. Two different byte sequences, identical on screen, in the same terminal, in the same font.
<?php
file_exists('créé.txt'); // false, in front of a file the Finder shows as créé.txt
?>
You will read that line for twenty minutes before you believe it. Normalize before you compare, before you store, and before you hash:
<?php $name = \Normalizer::normalize($name, \Normalizer::FORM_C); // ext-intl ?>
Two filenames are the same when they are the same after normalization and case folding. Not before.
Asking the filesystem what it actually has
file_exists() will lie to you on macOS, cheerfully and consistently. If you need the real name, you have to read the directory, because scandir(), glob() and DirectoryIterator return the preserved case, which is the only truth available.
The usual implementation:
<?php in_array(basename($path), scandir($dir), true); ?>
This works, and it works only for byte-identical names, which we have just established is the wrong test. in_array()gives you one comparison operator, ===, and no seat at the table. What we actually need is a comparison of our own:
<?php
function sameName(string $a, string $b): bool
{
$fold = static fn (string $s): string => mb_strtolower(
\Normalizer::normalize($s, \Normalizer::FORM_C)
);
return $fold($a) === $fold($b);
}
?>
And then a search that can use it. Since PHP 8.4:
<?php
$actual = array_find(
scandir($dir),
static fn (string $entry): bool => sameName($entry, $name)
);
?>
array_find() returns the on-disk spelling, or null. That is a much better answer than true, because now you can log the difference, correct it, or refuse it. Before 8.4, array_filter() with the same callback and a reset() does the job, slightly less elegantly.
The version that matters most is the third one: how many entries matched?
<?php $collisions = array_filter(scandir($dir), fn ($e) => sameName($e, $name)); ?>
On Linux, that array can contain two elements. On macOS it never will. If your upload directory is one filesystem and your backup target is another, that array is the difference between a copy and a data loss.
The rest of the minefield, briefly
Uploads. Photo.jpg from one user and photo.jpgfrom another are one file on macOS and two on Linux. Do not put user-supplied names on disk. Generate a UUID or a content hash for the filesystem, keep the original name in the database, where the collation is your decision and not the kernel’s.
Renaming case. rename('Foo.php', 'foo.php') is a no-op or an error, depending on the platform’s mood. Go through a temporary name and back. Git has the same illness: git mv Foo.php foo.php does nothing when core.ignorecase is true. Use --force, or two commits.
MySQL. lower_case_table_names defaults differently per platform, because it defaults to whatever the underlying filesystem does. Your table names can break in exactly the same way, at exactly the same moment, for exactly the same reason. It is a nice touch.
Put it in a class, once
All of the above is knowledge. Knowledge distributed across forty call sites is not knowledge, it is a liability with good intentions. Every file_exists(), every strtolower(pathinfo(...)), every str_starts_with($path, $base) is a place where someone will eventually write the simple version.
Write a Path class. Make it the only thing in the codebase that touches a raw string as a path.
<?php
final class Path
{
private function __construct(
private readonly string $value,
) {}
public static function of(string $raw): self
{
$normalized = \Normalizer::normalize($raw, \Normalizer::FORM_C);
if ($normalized === false) {
throw new \InvalidArgumentException('Path is not valid UTF-8.');
}
return new self($normalized);
}
/** The name as the filesystem actually spells it, or null. */
public function resolve(): ?string
{
$dir = \dirname($this->value);
if (!\is_dir($dir)) {
return null;
}
return \array_find(
\scandir($dir),
fn (string $entry): bool => $this->matches($entry),
);
}
/** True only if the case on disk is the case we asked for. */
public function existsExactly(): bool
{
return $this->resolve() === \basename($this->value);
}
public function extension(): string
{
return \mb_strtolower(\pathinfo($this->value, PATHINFO_EXTENSION));
}
public function isWithin(self $base): bool
{
$real = \realpath($this->value);
$root = \realpath($base->value);
return $real !== false
&& $root !== false
&& \str_starts_with($real . \DIRECTORY_SEPARATOR, $root . \DIRECTORY_SEPARATOR);
}
private function matches(string $entry): bool
{
return \mb_strtolower(\Normalizer::normalize($entry, \Normalizer::FORM_C))
=== \mb_strtolower(\basename($this->value));
}
}
?>
Nothing exotic. The value is that there is now one place to fix when you discover the next platform quirk, one place for a static analysis rule to point at, and one place where a code review can ask “why is this a string?”.
Two notes on that code. realpath() resolves symlinks and .., which is what you want for a containment check. But on macOS it does not correct the case, so it is not a canonicalizer for our purpose. And isWithin() appends a separator on both sides, because /var/www/uploads-old starts with /var/www/uploads and that is how sandboxes leak.
You may also want a Path::isFilesystemCaseSensitive() probe, which creates a temp file and looks for its lowercase twin, for a health check at boot. Treat it as a smoke test, not a strategy.
Or: fix the filesystem
The defensive code above is what you write for the filesystems you do not control: user uploads, network shares, someone’s Windows laptop, a Docker bind mount on macOS. For the filesystem you do control, there is a much shorter answer.
Use a case-sensitive one. Everywhere.
- macOS: Disk Utility, add an APFS volume, tick case-sensitive, put the project on it. It costs ten minutes and it is not a partition, it shares the container’s free space.
- Windows: WSL2, or
fsutil file setCaseSensitiveInfo <dir> enable, per-directory, and it does not apply retroactively to existing subdirectories, so do it early. - Docker on macOS: bind mounts inherit the host’s insensitivity. A named volume, or files baked into the image, gives you Linux behaviour.
- CI: run on Linux even when the whole team is on Mac. That, plus
--strict-psr, catches the autoloading class of bug before it reaches anyone.
The bonus is that case sensitivity is also the fast option. A case-sensitive lookup is a byte comparison against a hash. A case-insensitive lookup has to fold each component through Unicode case tables — which change between Unicode versions, which is why ext4’s optional casefold feature pins the encoding per-directory — before it can compare anything. It is a small cost, paid on every path resolution, on a code base that resolves a lot of paths.
Case-insensitivity is a feature designed to be kind to humans typing at a prompt. Your application is not typing at a prompt. It knows exactly which file it wants, it has known since compile time, and it would rather be told when it is wrong than be quietly forgiven.
Let it be told. Preferably before Friday.

