Файлы в 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
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
Одна и та же функция может открыть локальный файл, специальный PHP-поток или URL: поведение задаёт wrapper.

Эта тема стоит рядом с JSON, сериализация и форматы данных: JSON-тело HTTP-запроса часто читают именно через php://input. А когда речь идёт о пользовательских upload, граница уже переходит в Загрузка файлов, потому что там нельзя обращаться с временным файлом как с обычным доверенным путём.

Quick recall
Почему в PHP `file_get_contents('/tmp/a.txt')`, `fopen('php://input', 'rb')` и `fopen('https://example.com/data.json', 'r')` концептуально похожи?

Пути: относительные, абсолютные и канонические

Путь в 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;
}

Это не заменяет авторизацию. Проверка пути отвечает только на вопрос «не вышли ли мы из каталога», а не «имеет ли этот пользователь право читать этот отчёт».

Quick recall
Почему в приложении опасно полагаться на относительный путь вроде `../storage/report.txt`?

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 внутри одной директории даёт более аккуратную замену, чем «открыть файл и постепенно перезаписать содержимое».

Quick recall
Когда `file_get_contents()` удобен, а когда лучше читать через `fopen()` и цикл?

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-заголовки, ответы и редиректы и читает файл из закрытой директории.

См. также

Sources

  1. PHP Manual: Filesystem
  2. PHP Manual: Streams
  3. PHP Manual: Supported Protocols and Wrappers
  4. PHP Manual: php://
  5. PHP Manual: SplFileObject
  6. PHP Manual: Filesystem Security
  7. PHP Manual: realpath
  8. PHP Manual: fopen
  9. PHP Manual: HTTP context options
  10. PHP Manual: file_get_contents
  11. PHP Manual: file_put_contents