Перейти к содержимому
Поиск
Разработка

Flysystem: файлы и хранилища в PHP на практике

Работаем с файлами через Flysystem: запись, чтение, потоки, списки, копирование и обработка ошибок. Интерактивная схема хранилища, настоящий локальный PHP-проект и подключение S3.

Александр Некрасов
Flysystem — файлы и хранилища: локальная папка и облако связаны единым интерфейсом PHP

Сегодня приложение сохраняет отчёты в папку на сервере. Завтра вложения нужно перенести в объектное хранилище, а резервные копии — отправлять в другое место. Если работа с файлами распределена по контроллерам, смена хранилища превращается в поиск путей, HTTP-запросов и разных способов обработки ошибок.

Flysystem даёт PHP-приложению общий интерфейс: записать, прочитать, скопировать, переместить, удалить файл или получить его свойства. Адаптер переводит эти действия в операции локального диска, S3 и других хранилищ. Код, который создаёт отчёт, может работать с одной зависимостью и не знать расположение физических файлов.

В этой статье два уровня практики. На странице — интерактивная схема операций и генератор PHP: это браузерная модель, она не запускает PHP и ничего не записывает на сервер сайта. В ZIP — настоящий Composer-проект, в котором операции выполняет Flysystem. Ниже разобраны оба пути, команды запуска и границы каждого примера.

Когда достаточно обычного PHP, а когда нужен Flysystem

Для одного локального текстового файла вполне подходят file_put_contents и file_get_contents. PHP также умеет работать с потоками; библиотека не является обязательным условием экономного чтения больших файлов. Польза Flysystem появляется в общем контракте, адаптерах и последовательной обработке ошибок.

<?php
declare(strict_types=1);
$directory = __DIR__ . '/var/native';
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
    throw new RuntimeException('Не удалось создать каталог');
}
$written = file_put_contents($directory . '/note.txt', "Отчёт готов\n", LOCK_EX);
if ($written === false) {
    throw new RuntimeException('Не удалось записать файл');
}
echo "Записано байт: {$written}\n";

Сравнение с false строгое: запись пустого файла возвращает ноль байт, и это не ошибка. Если заменить папку на S3, такой вызов сам по себе не станет универсальной операцией с объектным хранилищем. Понадобится другой клиент, настройки и обработка его ответов.

Задача Обычный PHP Flysystem
Один локальный файл Прямые функции, мало кода Дополнительная зависимость может быть избыточна
Локальное и облачное хранение Разные API и обработчики Общие методы, разные адаптеры
Ошибки записи и чтения false, предупреждения, собственные исключения Исключения соответствующих операций
Большие файлы Есть fopen и потоки readStream/writeStream сохраняют общий контракт
Права пользователя на документ Проверяет приложение Также проверяет приложение

Подробнее: результат и режимы file_put_contents.

Не стоит переписывать стабильный локальный скрипт только ради абстракции. Но если файлы — часть сервиса с несколькими окружениями и хранилищами, общий интерфейс уменьшает число мест, которые придётся менять. При этом Flysystem не превращает облако в локальный диск: задержки, стоимость запросов и атомарность операций остаются разными.

Как связаны приложение, Filesystem и адаптер

Внешний контракт называется FilesystemOperator. Обычный экземпляр Filesystem принимает адаптер и делегирует ему работу. Бизнес-коду достаточно получить оператор через конструктор. При необходимости зависимость можно сузить до FilesystemReader или FilesystemWriter.

use League\Flysystem\FilesystemOperator;

final class ReportStorage
{
    public function __construct(private FilesystemOperator $files) {}

    public function save(string $reportId, array $rows): string
    {
        if (!preg_match('/\A[a-f0-9]{32}\z/', $reportId)) {
            throw new InvalidArgumentException('Некорректный ID отчёта');
        }
        $path = 'reports/' . $reportId . '.json';
        $json = json_encode($rows, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
        $this->files->write($path, $json);
        return $path;
    }
}

Метод создаёт содержимое и ключ хранения, но не выбирает bucket и не собирает абсолютный путь. Это решение находится в конфигурации приложения. Сам идентификатор не даёт права читать отчёт: проверка владельца выполняется до обращения к хранилищу.

Замена адаптера меняет место будущих операций. Уже записанные файлы никуда автоматически не переезжают. Для переноса данных нужен отдельный процесс: прочитать источник, записать назначение, проверить результат и только затем решать судьбу исходника.

Подробнее: архитектура и интерфейсы Flysystem.

Мастерская: проследите путь файловой операции

В мастерской один связанный набор файлов и девять рецептов. Выбор рецепта заполняет форму, а кнопка «Показать результат модели» выполняет выбранную операцию в браузере. Дерево, число файлов, объём данных и блок «До операции / После операции» помогают увидеть последствия. PHP-код текущей операции раскрывается ниже результата.

Начните с «Прочитать файл»: в исходном наборе есть welcome.txt, report.csv и notes.txt. Чтение покажет содержимое, но оставит три файла. Затем попробуйте «Узнать размер и MIME»: размер вычисляется в байтах UTF-8, поэтому русская строка занимает больше байтов, чем содержит видимых букв.

Граница демонстрации: схема показывает логику и генерирует исходник. Размеры, состояния и ошибки здесь относятся к модели браузера. Они не доказывают наличие файла на диске, работу PHP или соединение с облаком. Для этого запустите скачиваемый проект и проверьте реальные результаты описанными ниже командами.

Следующие упражнения начинайте с кнопки «Сбросить файлы мастерской», если хотите сравнить числа с описанием. Без сброса операции продолжают менять тот же набор — это полезно для самостоятельного маршрута «записать, прочитать, перенести, удалить».

  1. Записать заметку. Оставьте предложенный текст и выполните операцию: файлов станет четыре. Нажмите новый файл в дереве, чтобы подготовить чтение. Отображаемое имя и внутренний ключ отличаются: понятная подпись не обязана становиться путём хранения.
  2. Обойти папку. В исходной documents находятся два файла. Переключатель вложенных элементов соответствует аргументу listContents. В простом стартовом наборе нет вложенных подпапок, поэтому переключение не обязано менять список.
  3. Сделать копию отчёта. Получите четвёртый файл в exports. Исходный report.csv останется в documents; папка назначения появится вместе с записью.
  4. Перенести в архив. Путь welcome.txt изменится на archive/welcome.txt, но число файлов останется равным трём. Это наглядное отличие от копирования.
  5. Удалить заметку. После удаления notes.txt останутся два файла. Сброс возвращает учебный набор; в обычном приложении наличие корзины или восстановления нужно проектировать отдельно.
  6. Записать через поток. Посмотрите на ресурс PHP и finally в исходнике. Браузер моделирует результат маленького CSV; экономию памяти подтверждают настоящие потоковые операции проекта, а не эта схема состояния.
  7. Разобрать ошибку. Чтение фиксированного отсутствующего файла покажет ожидаемый класс UnableToReadFile. Дерево не должно изображать успешное чтение. В отдельном раскрывающемся блоке проверьте строку с ../: это правила приложения, а не обращение к диску.

Рецепты задают начальные параметры, но не ограничивают доступные операции: в списке также есть чтение потока, изменение visibility, создание и удаление папки. В этой мастерской приложение разрешает удалить только пустую папку; рекурсивное поведение самого Flysystem разобрано ниже. После изменения параметров старый результат очищается: он больше не относится к текущему запросу.

Установка и первый реальный файл

В материале используются league/flysystem 3.36.0 и локальный адаптер 3.35.3. Они распространяются по MIT. Сам Flysystem требует PHP от 8.0.2, а готовый проект статьи рассчитан на PHP 8.2+, в том числе из-за актуального AWS SDK в варианте S3. Для определения MIME локальному адаптеру нужно расширение fileinfo.

Если Composer пока незнаком, начните с руководства по установке зависимостей и командам Composer. В новой пустой папке выполните:

composer require league/flysystem:3.36.0 league/flysystem-local:3.35.3

Сохраните следующий полный файл как demo.php рядом с composer.json. При запуске он создаст var/storage/reports/hello.txt, прочитает его и выведет размер. Здесь путь фиксирован самим примером; пользовательский ввод не участвует.

<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';

use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemException;
use League\Flysystem\Local\LocalFilesystemAdapter;

$files = new Filesystem(new LocalFilesystemAdapter(__DIR__ . '/var/storage'));
try {
    $files->write('reports/hello.txt', "Hello, Flysystem!\n");
    echo $files->read('reports/hello.txt');
    echo 'Размер: ' . $files->fileSize('reports/hello.txt') . " байт\n";
} catch (FilesystemException $error) {
    fwrite(STDERR, 'Операция не выполнена: ' . $error::class . PHP_EOL);
    exit(1);
}
php demo.php

Ожидаемое содержимое — Hello, Flysystem! и перевод строки, размер — 18 байт. Повторный запуск перезапишет тот же файл. Это свойство write, а не случайная особенность примера.

В готовом проекте используйте composer install: он устанавливает версии из lock-файла. Не начинайте знакомство с composer update, если хотите воспроизвести проверенный набор зависимостей.

Подробнее: актуальные требования и версия пакета.

Подробнее: подключение LocalFilesystemAdapter.

Запись и чтение: важные отличия версии 3

write создаёт файл или заменяет его содержимое. Он не возвращает флаг успеха. Если выполнение дошло до следующей строки без исключения, операция завершилась; конструкция if ($files->write(...)) для современного API неверна.

$files->write('notes/draft.txt', 'Первая редакция');
$files->write('notes/draft.txt', 'Исправленная редакция');
$text = $files->read('notes/draft.txt');
echo $text; // Исправленная редакция

В старых статьях встречаются put, update и updateStream. В версии 3 вместо них используются write и writeStream. Не смешивайте примеры разных поколений библиотеки: иногда код выглядит привычно, но метода в установленном интерфейсе уже нет.

Если требуется сохранять каждую редакцию документа, создавайте разные ключи и храните связь между версиями в модели приложения. Проверка fileExists перед записью помогает показать предупреждение, но не резервирует имя: между проверкой и записью другой процесс может успеть изменить объект.

$revisionId = bin2hex(random_bytes(16));
$path = 'reports/versions/' . $revisionId . '.json';
$files->write($path, json_encode($report, JSON_THROW_ON_ERROR));
// После успешной записи приложение сохраняет ID и связь с отчётом.

Для одновременного редактирования нужны собственные правила конкурентного доступа или возможности конкретного хранилища. Общий вызов Flysystem не обещает условную запись «только если версия не изменилась» для всех адаптеров.

Подробнее: перезапись, удалённые методы и отказ от success boolean.

Существование, размер, MIME и время изменения

Содержимое и свойства — разные запросы. Для карточки вложения часто достаточно размера, MIME и даты изменения. Не загружайте весь файл в строку только для того, чтобы показать его размер рядом с кнопкой скачивания.

$path = 'reports/hello.txt';
$metadata = [
    'exists' => $files->fileExists($path),
    'bytes' => $files->fileSize($path),
    'mime' => $files->mimeType($path),
    'modifiedAt' => gmdate(DATE_ATOM, $files->lastModified($path)),
];
echo json_encode($metadata, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);

Этот фрагмент предполагает существующий файл из предыдущего примера. Если файл отсутствует, получение размера может завершиться исключением. fileExists() возвращает false при отсутствии, но невозможность выполнить проверку — отдельная ошибка. Например, отказ в доступе к хранилищу нельзя молча показывать пользователю как «файлов пока нет».

MIME помогает выбрать способ отображения, но сам по себе не доказывает, что загруженный документ безопасен. А время изменения не заменяет бизнес-дату создания отчёта. Для каталога приложения храните необходимые сведения отдельно и обновляйте их после успешных операций.

Подробнее: методы проверки существования и получения метаданных.

Список файлов без лишней загрузки в память

listContents возвращает объекты атрибутов, а не строки с абсолютными путями. Второй аргумент задаёт рекурсивный обход. У файла и каталога разные свойства; сначала определите тип элемента.

use League\Flysystem\FilesystemException;

try {
    foreach ($files->listContents('reports', true) as $item) {
        if (!$item->isFile()) continue;
        echo $item->path() . PHP_EOL;
    }
} catch (FilesystemException $error) {
    fwrite(STDERR, 'Не удалось получить список: ' . $error::class . PHP_EOL);
}

Обход основан на генераторе. Ошибка может возникнуть во время foreach, поэтому обработчик должен охватывать не только создание списка. Такой результат рассчитан на последовательное чтение: не используйте один исчерпанный генератор для нескольких повторных проходов.

Для небольшого списка можно отфильтровать файлы, отсортировать и материализовать массив. Для миллионов объектов это уже другая стоимость по памяти и времени: сортировка требует получить весь набор.

use League\Flysystem\StorageAttributes;

$paths = $files->listContents('reports', true)
    ->filter(fn (StorageAttributes $item) => $item->isFile())
    ->sortByPath()
    ->map(fn (StorageAttributes $item) => $item->path())
    ->toArray();

Не рассчитывайте на сортировку по умолчанию. И не превращайте каждый элемент списка в цепочку дополнительных сетевых запросов без необходимости. Для большого пользовательского каталога удобнее индексировать документы в базе: хранить ID, название, владельца, размер и ключ объекта, а хранилище использовать для байтов.

Подробнее: генераторы, фильтрация и сортировка списков.

Копирование, перемещение и удаление

Копия оставляет исходник, перемещение меняет его расположение, удаление убирает объект. В интерфейсе эти действия нужно различать: одинаковая кнопка «Готово» не объясняет пользователю, где теперь находится документ.

$files->copy('reports/hello.txt', 'archive/hello-copy.txt');
$files->move('archive/hello-copy.txt', 'archive/hello-final.txt');
$files->delete('archive/hello-final.txt');
// reports/hello.txt остаётся на месте.

Выбирайте назначение явно. Не обещайте, что существующий файл будет защищён от замены автоматически. Для команды «Создать копию» приложение может сформировать новый ID и показать пользователю новое название, сохранив исходное имя как метаданные.

На S3 перемещение реализуется через копирование и последующее удаление. Это не общая атомарная транзакция: при сбое возможна ситуация, когда копия уже создана, а исходник ещё существует. Журнал операции и повторяемый алгоритм важнее предположения, что любой move ведёт себя как локальный rename.

Подробнее: копирование, перемещение и переименование объектов S3.

deleteDirectory — отдельная рекурсивная операция. Её не стоит привязывать к произвольному пути из запроса. Если сервис удаляет вложения конкретной заявки, сначала определите разрешённую заявку и её фиксированную область хранения. Для обычного удаления одного вложения достаточно delete.

Большие файлы: readStream и writeStream

При read содержимое попадает в PHP-строку. Для крупного архива удобнее получить поток и передать его дальше. Это уменьшает необходимость держать весь файл в памяти приложения, хотя конкретный адаптер и SDK всё равно могут использовать свои буферы.

$stream = $source->readStream('exports/report.csv');
try {
    $destination->writeStream('imports/report.csv', $stream);
} finally {
    if (is_resource($stream)) fclose($stream);
}
// $source и $destination — два заранее настроенных FilesystemOperator.

Поток нужно закрыть и при успешной записи, и при исключении. Не делайте перед этим stream_get_contents, если цель — передавать данные постепенно: так вы снова получите весь файл в строке.

В текущем Flysystem writeStream перематывает доступный для позиционирования поток к началу. Для неперематываемого источника это невозможно. Передавайте свежий читаемый ресурс и не стройте алгоритм на предположении, что метод всегда начнёт с произвольной текущей позиции.

$stream = fopen(__DIR__ . '/input/report.csv', 'rb');
if ($stream === false) throw new RuntimeException('Не удалось открыть источник');
try {
    $files->writeStream('imports/report.csv', $stream);
} finally {
    fclose($stream);
}

При миграции между хранилищами сначала записывайте назначение и проверяйте результат. Удаление исходника после неудачной передачи недопустимо. Для больших наборов сохраняйте прогресс по ID файлов: тогда процесс можно продолжить, не начиная с самого первого объекта.

Подробнее: реализация writeStream и перемотки ресурса в версии 3.36.0.

Исключения: сообщаем результат и сохраняем причину

У каждой операции есть понятный тип ошибки: UnableToReadFile, UnableToWriteFile, UnableToCopyFile и другие. Общий интерфейс FilesystemException позволяет обработать ошибки хранилища в одном месте. Ожидаемый сбой чтения можно выделить отдельно.

use League\Flysystem\FilesystemException;
use League\Flysystem\UnableToReadFile;

try {
    $contents = $files->read('documents/missing.txt');
    echo $contents;
} catch (UnableToReadFile $error) {
    echo 'Не удалось прочитать документ. Проверьте его наличие и доступ.';
} catch (FilesystemException $error) {
    echo 'Хранилище временно недоступно для этой операции.';
}

Не возвращайте пустую строку при любой ошибке: пустой файл — допустимый результат, а сбой соединения — другое состояние. Не показывайте посетителю полный текст исключения без разбора: он может содержать физический путь или технические подробности подключения.

Для журнала полезны тип операции, ID документа и класс исключения. Развёрнутую диагностику сохраняйте в защищённом журнале. Настройка обработчиков и уровней разобрана в материале о Monolog.

// $logger — настроенный PSR-3/Monolog logger; ID получен приложением.
try {
    $files->delete($knownStorageKey);
    $logger->info('attachment.deleted', ['file_id' => $documentId]);
} catch (League\Flysystem\FilesystemException $error) {
    $logger->error('attachment.delete_failed', [
        'file_id' => $documentId,
        'error_type' => $error::class,
    ]);
    throw $error;
}

Повторять автоматически нужно не каждую ошибку. Неверный ID, отсутствие разрешения и недоступный сервер требуют разных действий. Для удалённых операций учитывайте неопределённый исход: сетевой ответ мог потеряться уже после записи. Новый случайный ключ на каждой попытке способен создать несколько копий одного документа.

Подробнее: типы исключений и сохранение исходной причины.

Пути, visibility и права пользователя

Путь Flysystem — ключ относительно корня адаптера. Для приложения полезно разделять три значения: ID документа, отображаемое имя и внутренний ключ. Пользователь может назвать файл «Акт за ноябрь.pdf», а хранилище — держать его под сгенерированным идентификатором.

$fileId = bin2hex(random_bytes(16));
$storageKey = 'attachments/' . $fileId . '.bin';
$displayName = 'Акт за ноябрь.pdf';
$files->write($storageKey, $validatedContents);
// В базе: fileId, storageKey, displayName, ownerId, MIME и размер.
// В запросе скачивания: fileId. storageKey сервер находит сам.

Не передавайте в read или delete произвольный $_GET['path']. Найдите документ по ID, проверьте доступ текущего пользователя и получите ключ из своей записи. Нормализация путей в библиотеке не заменяет эту проверку.

У локального адаптера также есть нюанс символических ссылок: режим DISALLOW_LINKS касается листинга, а чтение ведёт себя как обычный PHP и может следовать ссылке. Поэтому корневая папка адаптера сама по себе не является полной изоляцией. В демонстрационном HTTP-проекте используются известные ID и фиксированные папки, а не произвольные пути клиента.

Подробнее: корень локального адаптера и обработка символических ссылок.

public и private — упрощённые значения доступности, которые адаптер преобразует в собственные настройки. На локальной Unix-системе это связано с файловыми разрешениями; на Windows нельзя делать те же выводы только по POSIX-битам. В облаке действуют политики bucket, роли и другие правила.

use League\Flysystem\Visibility;

$files->write('documents/note.txt', 'Служебный документ', [
    'visibility' => Visibility::PRIVATE,
]);
$files->setVisibility('documents/note.txt', Visibility::PRIVATE);

Эти строки не создают авторизацию по пользователям и не настраивают веб-сервер. Файл с «private» в каталоге, который сервер уже раздаёт публично, нельзя считать защищённым только благодаря названию настройки. Закрытые документы храните вне публичного корня и выдавайте через проверяемый маршрут приложения.

Подробнее: пределы интерпретации visibility.

Выдача файла из PHP после проверки доступа

Для закрытого вложения пользователь обращается к маршруту вроде /documents/{id}/download. Контроллер проверяет владельца и получает внутренний ключ. Затем можно открыть поток и передать содержимое в ответ, не превращая весь файл в строку.

function outputTextDocument(
    League\Flysystem\FilesystemOperator $files,
    string $authorizedStorageKey
): void {
    // Ключ получен из записи, доступ к которой уже проверен контроллером.
    $stream = $files->readStream($authorizedStorageKey);
    try {
        header('Content-Type: text/plain; charset=UTF-8');
        header('Content-Disposition: attachment; filename="document.txt"');
        header('X-Content-Type-Options: nosniff');
        if (fpassthru($stream) === false) {
            throw new RuntimeException('Передача потока не завершена');
        }
    } finally {
        if (is_resource($stream)) fclose($stream);
    }
}

Здесь показан текстовый документ с фиксированным безопасным именем ответа. Для PDF, изображения или архива подставляйте проверенный MIME и правильно кодируйте имя скачивания. После начала передачи уже нельзя надёжно заменить тело ответа красивым JSON с ошибкой — сбой потока нужно учитывать отдельно в журнале и интерфейсе клиента.

Flysystem хранит и читает данные, но не заменяет загрузчик. Выбор файла, прогресс передачи, временная загрузка и подтверждение набора разобраны в практикуме FilePond. Его серверный обработчик после проверки файла может передать поток в Flysystem.

Переход к S3: тот же интерфейс, другие условия

Для S3 используется отдельный адаптер и AWS SDK. В версии материала адаптер — 3.35.3. Основные вызовы сохраняются, но вместо локального пути выбираются bucket и префикс ключей. Каталоги здесь — способ организации имён объектов, а не обязательные физические папки.

composer require league/flysystem-aws-s3-v3:3.35.3

Следующий конфигурационный фрагмент предполагает реальные переменные окружения и разрешённый bucket. Секреты не записаны в код: AWS SDK использует свою цепочку провайдеров, например переменные окружения, профиль или роль среды выполнения.

use Aws\S3\S3Client;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use League\Flysystem\Filesystem;

function requiredEnvironment(string $name): string
{
    $value = getenv($name);
    if ($value === false || trim($value) === '') {
        throw new RuntimeException('Не задана настройка ' . $name);
    }
    return $value;
}

$client = new S3Client([
    'version' => 'latest',
    'region' => requiredEnvironment('AWS_REGION'),
]);
$adapter = new AwsS3V3Adapter(
    $client,
    requiredEnvironment('S3_BUCKET'),
    'application'
);
$s3Files = new Filesystem($adapter);

version: latest здесь относится к модели API SDK, а не отменяет закрепление Composer-зависимостей. Для S3-совместимого сервиса могут понадобиться endpoint и use_path_style_endpoint; берите их из документации конкретного провайдера, а не переносите параметры одного стенда во все окружения.

Настройка .env сама не загружается в PHP. Если используете phpdotenv с createImmutable, значения обычно читаются из $_ENV, а не автоматически появляются в getenv(). Этот выбор подробно разобран в статье о .env и переменных окружения.

Подробнее: подключение AWS S3 адаптера.

Подробнее: цепочка получения credentials в AWS SDK.

Отдельно проверьте настройки ACL. У новых bucket Amazon S3 по умолчанию отключены ACL, и перенос привычного public/private в запрос может закончиться AccessControlListNotSupported. Не включайте публичный доступ только ради устранения ошибки. Для bucket с режимом Bucket owner enforced допустим, в частности, следующий параметр записи; доступ при этом по-прежнему определяется политиками.

$s3Files->write('reports/hello.txt', 'Пример', [
    'ACL' => 'bucket-owner-full-control',
]);
// Конфигурация для соответствующего режима Amazon S3, не универсальный
// переключатель авторизации для всех S3-совместимых сервисов.

Общий API помогает вынести различия в конфигурацию, но проверка конкретного провайдера остаётся обязательной частью внедрения. Успешная запись на локальном диске ничего не говорит о роли IAM, bucket policy или доступности сети.

Подробнее: отключённые ACL и Bucket owner enforced.

Проверенный S3-стенд без облачной учётной записи

В ZIP есть отдельная папка examples/s3. Она соединяет настоящий PHP, Flysystem S3-адаптер и AWS SDK с локальным эмулятором Moto 5.2.5. Это позволяет увидеть реальные HTTP-запросы и ответы S3 API без создания аккаунта Amazon. Docker нужен только для этого дополнительного стенда; основной локальный адаптер работает без него.

Откройте терминал в examples/s3 и выполните команды. У этой папки собственный Composer lock-файл: зависимости S3 не подменяют зависимости основной мастерской.

composer install --no-plugins --no-scripts
docker compose up -d --wait
php smoke.php
docker compose down

Compose публикует порт только как 127.0.0.1:8775. В тесте зафиксированы вымышленные ключи и этот локальный endpoint; реальные credentials окружения не используются. Каждый запуск создаёт отдельный временный bucket, работает внутри префикса application/ и удаляет созданные объекты и bucket при завершении.

На проверенном стенде выполнены два последовательных прогона по 19 успешных проверок: запись и перезапись, чтение точных байтов, размер и MIME, время изменения, рекурсивный и обычный листинг, копирование, перенос, удаление и исключение отсутствующего объекта. Отдельно поток передал 2 МиБ; размер и SHA-256 прочитанного содержимого совпали с источником. Использованы Flysystem 3.36.0, адаптер 3.35.3 и AWS SDK 3.403.0.

Успешный запуск выводит JSON со значениями status: PASS, checks: 19 и cleanup_error: null. Ошибка очистки считается неуспехом даже после правильных файловых операций. Последняя команда останавливает стенд; его данные временные. Подробности и сохранённые отчёты лежат рядом со скриптом.

Здесь реально проверена связка PHP → SDK → HTTP → эмулятор. Moto не воспроизводит все условия рабочего облака: успешный тест не подтверждает IAM-политику, настройки реального bucket, стоимость запросов или поведение провайдера при сбоях. Перед подключением рабочего хранилища повторите необходимые сценарии в его тестовом окружении.

Подробнее: серверный режим Moto и официальный Docker-образ.

Публичные и временные ссылки

publicUrl формирует адрес, если адаптер умеет это делать или вы настроили базовый URL. Метод не создаёт маршрут веб-сервера, не переносит файл в публичную папку и не отменяет ограничения доступа.

$publicFiles = new League\Flysystem\Filesystem($adapter, [
    'public_url' => 'https://files.example.org/assets/',
]);
$url = $publicFiles->publicUrl('images/cover.webp');
// files.example.org должен действительно раздавать это хранилище.

Для закрытого объекта некоторые адаптеры поддерживают временную подписанную ссылку. Приложение сначала проверяет право пользователя получить документ, затем создаёт адрес с ограниченным сроком действия.

$url = $s3Files->temporaryUrl(
    'reports/hello.txt',
    new DateTimeImmutable('+5 minutes')
);
// Передавайте ссылку разрешённому получателю; не записывайте её в общий лог.

Такой адрес работает как предъявляемый доступ: получивший ссылку может воспользоваться ею в пределах действующих правил. Она не проверяет личность получателя сама по себе. Локальный адаптер не получает полноценные временные HTTP-ссылки автоматически — для него нужен свой маршрут выдачи или генератор ссылок.

Подробнее: генерация публичного адреса.

Подробнее: временные ссылки и поддерживающие их адаптеры.

Как проверить перенос и не перепутать checksum с ETag

Совпадение имени и размера ещё не доказывает совпадение содержимого. Для проверки переноса можно сравнить контрольные суммы с одинаковым алгоритмом. Явно задавайте его, если источник и назначение используют разные адаптеры.

$sourceHash = $source->checksum('reports/report.csv', ['checksum_algo' => 'sha256']);
$targetHash = $destination->checksum('imports/report.csv', ['checksum_algo' => 'sha256']);
if (!hash_equals($sourceHash, $targetHash)) {
    throw new RuntimeException('Содержимое после переноса отличается');
}

По умолчанию разные адаптеры могут возвращать разные типы значений. В частности, ETag S3 нельзя автоматически считать универсальным MD5 содержимого. Если нужный алгоритм не предоставляется хранилищем, Flysystem может прочитать поток и вычислить хеш самостоятельно. Для большого объекта это дополнительная передача данных, а не бесплатный запрос метаданных.

Для массовой миграции сохраняйте результат каждой операции: ID, источник, назначение, размер и выбранную контрольную сумму. Разделите стадии «скопировано», «проверено» и «исходник удалён». Тогда повтор после сбоя будет продолжением процесса, а не угадыванием, что успело выполниться.

Подробнее: алгоритмы, ETag и потоковый fallback.

Готовый проект: от схемы к настоящему PHP

В архиве есть Composer-зависимости в виде описания и lock-файла, исходники, команды и ограниченная файловая мастерская. Установка зависимостей выполняется локально. Начните с README и чистого демонстрационного пространства, чтобы результаты не смешивались с рабочими файлами.

Скачать PHP-проект и исходники мастерской Flysystem.

composer install
php -S 127.0.0.1:8080 -t public

Откройте http://127.0.0.1:8080/ в браузере. Команда привязывает сервер только к локальному адресу. Настоящий интерфейс проекта выполняет операции через PHP, а публичная статья остаётся браузерной схемой. Проверяйте метку режима и источник результата: на сайте кнопка называется «Показать результат модели», в скачанном проекте — «Выполнить на PHP».

HTTP-мастерская принимает известные ID файлов и папок. Отображаемое имя отделено от внутреннего пути, размер текста ограничен. Это позволяет изучить API на небольшом понятном наборе, не открывая посетителю доступ к произвольным каталогам компьютера. Сброс относится только к выделенному демонстрационному пространству.

Для своей интеграции проверьте минимум три класса результатов: успешную операцию, ожидаемую ошибку и повтор после частичного выполнения. Тест должен читать реальные байты из используемого адаптера. Красивое дерево в интерфейсе или успешная генерация PHP-кода такой проверки не заменяют.

В проекте также есть девять исполняемых CLI-рецептов. После установки Composer-зависимостей запустите их из корня проекта; веб-сервер для этих команд не требуется. Первая команда показывает идентификаторы рецептов, вторая выполняет весь набор, третья — только потоковую запись и чтение. Последняя запускает проверки настоящего Flysystem:

php bin/recipes.php --list
php bin/recipes.php all
php bin/recipes.php streams
php tests/run.php

Каждый запуск рецептов создаёт новый каталог var/recipes/run-*, а внутри него каждый рецепт получает собственный корень. Результаты печатаются в JSON; файлы остаются для изучения. Можно сравнить байты и пути с ответом команды, не смешивая их с данными HTTP-мастерской. Тестовый запуск при успехе выводит строку PASS; если проверка не проходит, сначала разберите её сообщение, прежде чем переносить пример в свой сервис.

Подробнее: почему интеграционные проверки работают с настоящим хранилищем.

Частые ошибки и что проверить сначала

Симптом Причина или проверка Что изменить
Метод put или update не найден Пример относится к старому API Использовать write/writeStream версии 3
Файл записан, условие успеха не сработало write возвращает void Обрабатывать исключения, а не boolean
Пустой список после повторного foreach Генератор уже прочитан Получить новый список или осознанно сохранить массив
private-файл доступен по URL Веб-сервер или политика уже раздаёт объект Исправить маршрут/политику и проверку доступа
Переезд адаптера «потерял» старые файлы Изменена конфигурация, данные не перенесены Выполнить отдельную миграцию и проверку
S3 отвечает AccessControlListNotSupported Bucket отключил ACL Согласовать параметры адаптера с политикой bucket
Поток пустой или закрыт Передан неподходящий ресурс/неверный lifecycle Открыть читаемый поток и закрывать его в finally
В браузерной схеме всё хорошо, PHP падает Модель не проверяет окружение сервера Проверить Composer, расширения, права и реальный запрос

Начинайте внедрение с одного сценария: например, сохранения отчётов или выдачи закрытых вложений. Установите понятный контракт ID и путей, проверьте запись и чтение, добавьте обработку ошибок и журнал. После этого подключайте второй адаптер и проверяйте именно его особенности, сохраняя общий интерфейс для бизнес-кода.

Документация и связанные материалы

  • Flysystem — официальная документация и список адаптеров.
  • Filesystem API — методы чтения, записи и метаданных.
  • Обработка ошибок — исключения отдельных операций.
  • Листинг — генераторы, фильтрация и сортировка.
  • Пакет в Packagist — версии, зависимости и требования PHP.
  • Composer — установка и воспроизведение зависимостей.
  • Monolog и phpdotenv — журнал операций и конфигурация окружения.
  • FilePond — пользовательский интерфейс выбора и загрузки файлов.