Обработка файлов в PHP: типичные ошибки и как их не допускать
Пять ошибок при работе с файлами в PHP: молчаливый провал file_put_contents, гонки при записи, загрузка по расширению вместо MIME и утечка памяти на больших файлах. С кодом и объяснением, чем платишь за каждую.

Скрипт пишет лог через file_put_contents, возвращает пользователю «сохранено», а файл на диске пустой: закончилось место, но код этого не заметил. Или загрузка аватарок принимает любой файл с расширением .jpg, включая PHP-скрипт, переименованный руками. Работа с файлами в PHP выглядит простой — четыре-пять функций — и именно поэтому в ней стабильно повторяются одни и те же ошибки. Разберём их на PHP 8.x: что именно ломается, чем это грозит в проде и как писать так, чтобы не приходилось искать причину по логам.
Ошибка 1. Игнорировать результат записи и чтения
Функции file_put_contents, fwrite, file_get_contents возвращают false при ошибке, но никто не заставляет вас проверять возврат. Диск заполнен, права сброшены, путь указывает в несуществующую директорию — код продолжает выполняться, будто всё хорошо.
// Плохо: результат отброшен, ошибку никто не увидит
file_put_contents($path, $data);
// Лучше: проверяем и различаем частичную запись
$written = file_put_contents($path, $data, LOCK_EX);
if ($written === false) {
throw new RuntimeException("Не удалось записать файл: $path");
}
if ($written !== strlen($data)) {
throw new RuntimeException("Записано $written из " . strlen($data) . " байт");
}
Проверка на strlen($data) ловит самый коварный случай — частичную запись, когда место кончилось посреди операции. Функция вернула не false, а число записанных байт, меньшее ожидаемого. Без сравнения вы получите обрезанный файл и «успешный» ответ.
Строгое сравнение обязательно
Ещё одна ловушка — сравнение через ==. Пустой файл при чтении вернёт пустую строку "", а "" == false в PHP истинно. То есть на корректном пустом файле сработает ветка ошибки. Всегда сравнивайте результат чтения через === с false.
Ошибка 2. Гонки при одновременной записи
Два процесса пишут в один лог или счётчик. Классический сценарий: прочитали значение, увеличили, записали обратно. Между чтением и записью успел вклиниться второй процесс — одно из увеличений потерялось.
file_put_contents с флагом LOCK_EX берёт эксклюзивную блокировку на время записи, но не защищает связку «прочитал — изменил — записал»: между вашим чтением и записью замок не держится. Для read-modify-write нужен явный flock на всё время операции.
$fp = fopen($counterFile, 'c+');
if ($fp === false) {
throw new RuntimeException('Не удалось открыть счётчик');
}
// Блокируем до конца операции, а не только на запись
if (!flock($fp, LOCK_EX)) {
fclose($fp);
throw new RuntimeException('Не удалось получить блокировку');
}
$current = (int) stream_get_contents($fp);
$current++;
rewind($fp);
ftruncate($fp, 0); // обнуляем перед записью нового значения
fwrite($fp, (string) $current);
fflush($fp); // сброс буфера до снятия блокировки
flock($fp, LOCK_UN);
fclose($fp);
Важные детали: ftruncate нужен, иначе при записи более короткого значения в файле останется хвост от старого. fflush перед flock($fp, LOCK_UN) гарантирует, что данные уйдут из буфера PHP до снятия замка.
Блокировка через flock работает на уровне процесса и файловой системы. На сетевых файловых системах вроде NFS её поведение не гарантировано — если у вас несколько серверов пишут в общий каталог, полагаться на flock нельзя, нужна внешняя блокировка (Redis, БД).
Ошибка 3. Определять тип загруженного файла по расширению
Проверка вида if (str_ends_with($name, '.jpg')) не проверяет ничего. Расширение — это часть имени, которое присылает клиент, оно не связано с содержимым. Файл shell.php, переименованный в photo.jpg, пройдёт такую проверку, а если он попадёт в директорию, откуда веб-сервер исполняет PHP, — это удалённое выполнение кода.
Проверять нужно фактическое содержимое через finfo, а расширение при сохранении задавать самому на основе определённого MIME, а не брать из загрузки.
// $_FILES['upload']['tmp_name'] — временный путь загруженного файла
$tmp = $_FILES['upload']['tmp_name'];
// Проверяем, что файл действительно пришёл через HTTP-загрузку
if (!is_uploaded_file($tmp)) {
throw new RuntimeException('Некорректная загрузка');
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($tmp);
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
if (!isset($allowed[$mime])) {
throw new RuntimeException("Тип не разрешён: $mime");
}
// Имя генерируем сами, расширение — из проверенного MIME
$safeName = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$dest = '/var/www/uploads/' . $safeName;
move_uploaded_file($tmp, $dest);
Два принципа здесь важнее конкретного кода. Первый: имя файла на диске генерируете вы, а не пользователь — так исключаются подстановки путей вида ../../ и коллизии. Второй: каталог загрузок должен лежать вне корня, из которого веб-сервер исполняет скрипты, либо в нём должно быть запрещено выполнение PHP на уровне конфигурации Nginx или Apache.
Ошибка 4. Читать большой файл целиком в память
file_get_contents и file() загружают весь файл в память. На конфиге по умолчанию с memory_limit в районе 128 МБ файл на 200 МБ уронит скрипт с Allowed memory size exhausted. При обработке загрузок или логов размер заранее неизвестен, поэтому построчное чтение безопаснее.
// Плохо на больших файлах: весь файл в память
$lines = file('huge.log');
foreach ($lines as $line) { /* ... */ }
// Хорошо: читаем по одной строке, память постоянна
$fp = fopen('huge.log', 'r');
if ($fp === false) {
throw new RuntimeException('Не удалось открыть файл');
}
while (($line = fgets($fp)) !== false) {
// обработка строки
}
fclose($fp);
Обратите внимание на !== false: fgets вернёт пустую строку для пустой строки в файле, и только false означает конец файла или ошибку. Сравнение через while ($line = fgets($fp)) без строгой проверки оборвётся на первой пустой строке.
Когда генератор удобнее цикла
Если чтение и обработка разнесены по коду, оберните построчное чтение в генератор — вызывающий код не будет знать про дескрипторы и получит ту же постоянную память:
function readLines(string $path): Generator
{
$fp = fopen($path, 'r');
if ($fp === false) {
throw new RuntimeException("Не открыть: $path");
}
try {
while (($line = fgets($fp)) !== false) {
yield rtrim($line, "\r\n");
}
} finally {
fclose($fp); // закроется даже при исключении в цикле обработки
}
}
Ошибка 5. Оставлять открытые дескрипторы и незакрытые ресурсы
Открыли файл через fopen, дальше по коду выбросилось исключение, fclose не вызвался. Один такой случай незаметен, но в цикле или под нагрузкой дескрипторы копятся и упираются в системный лимит открытых файлов — процесс начинает получать Too many open files. Спасает блок try/finally, как в генераторе выше, или обёртка ресурса в объект, который закроет дескриптор в деструкторе.
Сводка ошибок и последствий
| Ошибка | Чем грозит | Что делать |
|---|---|---|
| Не проверять возврат записи | Пустой или обрезанный файл, «успешный» ответ | Сравнить с false и с strlen |
== вместо === | Ложное срабатывание на пустом файле | Строгое сравнение с false |
| Нет блокировки при read-modify-write | Потеря данных при конкуренции | flock на всю операцию |
| Тип по расширению | Загрузка исполняемого файла, RCE | finfo + свой каталог вне корня |
| Чтение целиком в память | Падение по memory_limit | Построчное чтение fgets |
| Незакрытые дескрипторы | Too many open files | try/finally или деструктор |
Что учесть перед выкладкой
Настройте отдельный обработчик так, чтобы предупреждения файловых функций не тонули: по умолчанию fopen на несуществующем пути выдаёт E_WARNING и возвращает false, и если предупреждения подавлены оператором @, вы потеряете и то, и другое. Оператор подавления @ в файловом коде — почти всегда признак того, что ошибка проглатывается вместо обработки.
На проде проверьте три вещи: реальный memory_limit в конфиге PHP-FPM (он может отличаться от CLI), upload_max_filesize и post_max_size для загрузок, и системный лимит открытых файлов через ulimit -n для пользователя, под которым работает PHP. Атомарную запись критичных файлов делайте через временный файл и rename: запись во временный, затем атомарное переименование — читатели никогда не увидят полузаписанный файл.
Проверка грамотности: Орфография, пунктуация, стиль — с объяснением каждой правки
Спросить за 10 ₽Источники: PHP Manual: Filesystem Functions, PHP Manual: Handling file uploads
Частые вопросы
Работает ли этот код на PHP 7.4?
Почему file_put_contents с LOCK_EX не спасает от гонок?
Что делать, если finfo определяет тип неверно?
Не сработало: скрипт всё равно падает по памяти при построчном чтении
Грозит ли утечка дескрипторов проблемами на небольшом сайте?
Зачем генерировать имя файла самому, если можно очистить пользовательское?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.