Файлы в PHP: обычные пути и потоки
Работа с файлами в PHP выглядит простой: прочитать конфиг, сохранить лог, отдать пользователю CSV, разобрать загруженный файл. Но под этой простотой есть важная модель: большинство файловых функций PHP работают не только с локальной файловой системой, а со stream — потоком байтов. Поэтому file_get_contents('/tmp/a.txt'), fopen('php://input', 'rb') и fopen('https://example.com/data.json', 'r') используют одну общую идею, но разные wrappers.
Wrapper — это обработчик схемы в пути. В file:///var/app/data.txt схема file:// означает локальную файловую систему. В php://input схема php:// даёт доступ к специальному PHP-потоку. В http:// и https:// PHP может читать удалённый ресурс, если это разрешено настройками. Если схема не указана, PHP обычно работает с локальным файлом.
flowchart TD
A[Строка пути в PHP] --> B{Есть схема?}
B -->|нет| C[file:// локальный файл]
B -->|file://| C
B -->|php://| D[Специальные PHP-потоки]
B -->|http:// или https://| E[HTTP wrapper при allow_url_fopen]
C --> F[file_get_contents / fopen / SplFileObject]
D --> F
E --> G[stream context: method, headers, timeout]
F --> H[Чтение или запись байтов]
G --> HЭта тема стоит рядом с JSON, сериализация и форматы данных: JSON-тело HTTP-запроса часто читают именно через php://input. А когда речь идёт о пользовательских upload, граница уже переходит в Загрузка файлов, потому что там нельзя обращаться с временным файлом как с обычным доверенным путём.
Пути: относительные, абсолютные и канонические
Путь в PHP — строка. Проблема в том, что строка ../storage/report.txt ещё не говорит, куда реально попадёт код. Относительный путь считается от текущей рабочей директории процесса, а она в CLI, PHP-FPM и тестах может отличаться. Для кода приложения обычно надёжнее строить пути от известной базы: корня проекта, директории storage, temp-директории.
<?php
$baseDir = __DIR__ . '/storage/reports';
$path = $baseDir . '/daily.json';
$json = file_get_contents($path);
if ($json === false) {
throw new RuntimeException('Не удалось прочитать файл отчёта');
}realpath() возвращает канонический абсолютный путь: разворачивает symbolic links, . и ... Но он возвращает false, если файл не существует, поэтому для будущего файла сначала проверяют существующую родительскую директорию.
<?php
$base = realpath(__DIR__ . '/storage/reports');
if ($base === false) {
throw new RuntimeException('Директория отчётов не найдена');
}
$name = 'daily.json';
$target = $base . DIRECTORY_SEPARATOR . $name;Для путей от пользователя нельзя просто склеить $_GET['file'] с директорией. Значения вроде ../../.env или ../etc/passwd — классический path traversal. Минимальная защита: принимать не произвольный путь, а короткий идентификатор или имя файла по whitelist-правилу, затем проверять, что итоговый путь остался внутри разрешённой директории.
<?php
function safeReportPath(string $file): string
{
$base = realpath(__DIR__ . '/storage/reports');
if ($base === false) {
throw new RuntimeException('Storage не настроен');
}
if (!preg_match('/\A[a-z0-9_-]+\.json\z/', $file)) {
throw new InvalidArgumentException('Некорректное имя файла');
}
$path = $base . DIRECTORY_SEPARATOR . $file;
$real = realpath($path);
if ($real === false || !str_starts_with($real, $base . DIRECTORY_SEPARATOR)) {
throw new RuntimeException('Файл вне разрешённой директории');
}
return $real;
}Это не заменяет авторизацию. Проверка пути отвечает только на вопрос «не вышли ли мы из каталога», а не «имеет ли этот пользователь право читать этот отчёт».
file_get_contents() и file_put_contents()
file_get_contents() читает весь поток в строку. Это удобно для небольших файлов: JSON-конфиг, шаблон письма, маленький кеш. Для больших файлов это плохой выбор: строка целиком окажется в памяти PHP-процесса.
<?php
$raw = file_get_contents(__DIR__ . '/settings.json');
if ($raw === false) {
throw new RuntimeException('settings.json недоступен');
}
$settings = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);file_put_contents() симметрично записывает строку в файл. Для добавления в конец есть FILE_APPEND, для advisory lock на время записи — LOCK_EX. Lock не делает файл «магически транзакционным», но помогает не смешать две конкурентные записи в типичном локальном сценарии.
<?php
$line = json_encode([
'event' => 'login',
'user_id' => 42,
], JSON_THROW_ON_ERROR) . PHP_EOL;
$bytes = file_put_contents(
__DIR__ . '/storage/app.log',
$line,
FILE_APPEND | LOCK_EX,
);
if ($bytes === false) {
throw new RuntimeException('Не удалось записать лог');
}Для атомарного обновления важного файла часто пишут во временный файл в той же директории, затем делают rename(). На большинстве обычных файловых систем rename внутри одной директории даёт более аккуратную замену, чем «открыть файл и постепенно перезаписать содержимое».
fopen(), режимы и чтение по частям
fopen() открывает поток и возвращает resource или false. Режимы важны: 'r' — чтение, 'w' — запись с обнулением файла, 'a' — append, 'x' — создать новый файл и упасть, если он уже есть, 'c' — открыть для записи без немедленного truncate. Для бинарных данных добавляют b: 'rb', 'wb'.
<?php
$handle = fopen(__DIR__ . '/large.csv', 'rb');
if ($handle === false) {
throw new RuntimeException('CSV недоступен');
}
try {
while (($line = fgets($handle)) !== false) {
// Обрабатываем строку, не загружая весь файл в память.
echo trim($line) . PHP_EOL;
}
} finally {
fclose($handle);
}Такой стиль нужен для больших логов, CSV, экспортов и входных потоков. Он также делает ошибки видимее: вы явно открываете ресурс, читаете, закрываете. Это та же дисциплина, что и в Ошибки, исключения и Throwable: не прятать отказ функции за «ну вернулось false».
SplFileObject: файл как итератор
SplFileObject из SPL даёт объектную оболочку над файлом. Он полезен, когда файл естественно читается построчно или как CSV. Объект реализует итератор, поэтому его можно использовать в foreach. Это связывает тему с SPL, итераторы и коллекции: файл становится источником последовательности, а не одной большой строкой.
<?php
$file = new SplFileObject(__DIR__ . '/users.csv', 'rb');
$file->setFlags(
SplFileObject::READ_CSV
| SplFileObject::READ_AHEAD
| SplFileObject::SKIP_EMPTY
| SplFileObject::DROP_NEW_LINE
);
$file->setCsvControl(',', '"', '\\');
foreach ($file as $row) {
if ($row === [null] || $row === false) {
continue;
}
[$email, $name] = $row;
echo $email . ' -> ' . $name . PHP_EOL;
}У SplFileObject есть нюанс: открытый объект держит файловый handle, пока объект жив. Если на Windows файл не удаляется после чтения, проверьте, не осталась ли ссылка на SplFileObject; иногда достаточно присвоить $file = null.
php://input, php://temp и специальные потоки
php://input — read-only поток с сырым телом HTTP-запроса. Его используют для JSON, XML, webhook payload и других тел, которые не являются обычной HTML-формой. Для multipart/form-data с включённым стандартным чтением POST этот поток не используется как обычный источник upload-данных; для файлов смотрите $_FILES и Загрузка файлов.
<?php
$body = file_get_contents('php://input');
if ($body === false || $body === '') {
http_response_code(400);
echo 'Пустое тело запроса';
return;
}
try {
$data = json_decode($body, true, 64, JSON_THROW_ON_ERROR);
} catch (JsonException) {
http_response_code(400);
echo 'Некорректный JSON';
return;
}php://output пишет в output buffer, php://memory держит временный поток в памяти, php://temp держит данные в памяти до лимита, а затем использует временный файл. Это удобно, когда API ожидает stream, а вы не хотите создавать постоянный файл.
<?php
$tmp = fopen('php://temp/maxmemory:1048576', 'w+b');
fwrite($tmp, "id,name\n1,Anna\n");
rewind($tmp);
echo stream_get_contents($tmp);
fclose($tmp);Stream contexts и удалённые URL
Если allow_url_fopen включён, fopen() и file_get_contents() могут читать HTTP(S)-URL. Для серьёзного HTTP-клиента обычно берут отдельную библиотеку или PSR-совместимый клиент из PSR-7, middleware и HTTP-клиенты. Но для простого запроса stream context бывает достаточен: можно задать method, headers, body, timeout, redirect-поведение.
<?php
$payload = json_encode(['query' => 'php'], JSON_THROW_ON_ERROR);
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => [
'Content-Type: application/json',
'Connection: close',
],
'content' => $payload,
'timeout' => 3.0,
'ignore_errors' => true,
],
]);
$response = file_get_contents('https://example.com/search', false, $context);
if ($response === false) {
throw new RuntimeException('HTTP-запрос не выполнен');
}Не передавайте пользовательский URL прямо в file_get_contents(). Иначе вы легко получите SSRF: сервер начнёт ходить туда, куда попросил пользователь, включая внутренние адреса инфраструктуры. Для внешних URL нужны allowlist доменов, ограничения схемы, таймауты и отдельная обработка редиректов.
Permissions и безопасная модель доступа
PHP работает с правами пользователя процесса: в вебе это может быть пользователь PHP-FPM или веб-сервера, в CLI — ваш shell-пользователь. Если файл «существует», но процессу нельзя его читать, fopen() или file_get_contents() вернут ошибку. Функции is_readable(), is_writable(), fileperms() помогают диагностировать, но не являются полноценной защитой от гонок: между проверкой и использованием состояние файла может измениться.
Практическое правило: приложение должно читать и писать только в заранее выделенные директории: storage, var, tmp, upload-каталог вне public web root. Секреты, .env, исходники и временные upload не должны быть доступны как статические файлы из браузера. Если нужен скачиваемый файл, лучше отдавать его через контроллер, который проверяет авторизацию, выставляет корректные HTTP-заголовки, ответы и редиректы и читает файл из закрытой директории.
См. также
- JSON, сериализация и форматы данных — чтение JSON из
php://inputи обработкаJsonException. - Загрузка файлов —
$_FILES,is_uploaded_file(),move_uploaded_file()и доверенная обработка upload. - SPL, итераторы и коллекции —
SplFileObject, итераторы и объектная работа с последовательностями. - GET, POST и фильтрация ввода — почему пользовательский путь или URL сначала валидируют, а не «чистят» на глаз.
- PSR-7, middleware и HTTP-клиенты — когда stream wrappers уже недостаточны для HTTP-интеграций.
- Конфигурация безопасности PHP — настройки вроде
open_basedir,allow_url_fopenи ошибки на продакшене.