---
title: "The Case Of The Insensitive Filesystem"
url: https://www.exakat.io/the-case-of-the-insensitive-filesystem/
date: 2026-08-06
modified: 2026-08-06
author: "dams"
description: "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..."
categories:
  - "Code auditing"
tags:
  - "files"
  - "wrong case"
image: https://www.exakat.io/wp-content/uploads/2026/08/case.320.jpg
word_count: 1710
---

# The Case Of The Insensitive Filesystem

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

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:

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.

You will read that line for twenty minutes before you believe it. Normalize before you compare, before you store, and before you hash:

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:

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:

And then a search that can use it. Since PHP 8.4:

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

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.jpg`from 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.

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.