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

Сегодня приложение сохраняет отчёты в папку на сервере. Завтра вложения нужно перенести в объектное хранилище, а резервные копии — отправлять в другое место. Если работа с файлами распределена по контроллерам, смена хранилища превращается в поиск путей, HTTP-запросов и разных способов обработки ошибок.
Flysystem даёт PHP-приложению общий интерфейс: записать, прочитать, скопировать, переместить, удалить файл или получить его свойства. Адаптер переводит эти действия в операции локального диска, S3 и других хранилищ. Код, который создаёт отчёт, может работать с одной зависимостью и не знать расположение физических файлов.
Когда достаточно обычного 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 или соединение с облаком. Для этого запустите скачиваемый проект и проверьте реальные результаты описанными ниже командами.
Следующие упражнения начинайте с кнопки «Сбросить файлы мастерской», если хотите сравнить числа с описанием. Без сброса операции продолжают менять тот же набор — это полезно для самостоятельного маршрута «записать, прочитать, перенести, удалить».
- Записать заметку. Оставьте предложенный текст и выполните операцию: файлов станет четыре. Нажмите новый файл в дереве, чтобы подготовить чтение. Отображаемое имя и внутренний ключ отличаются: понятная подпись не обязана становиться путём хранения.
- Обойти папку. В исходной
documentsнаходятся два файла. Переключатель вложенных элементов соответствует аргументуlistContents. В простом стартовом наборе нет вложенных подпапок, поэтому переключение не обязано менять список. - Сделать копию отчёта. Получите четвёртый файл в
exports. Исходныйreport.csvостанется вdocuments; папка назначения появится вместе с записью. - Перенести в архив. Путь
welcome.txtизменится наarchive/welcome.txt, но число файлов останется равным трём. Это наглядное отличие от копирования. - Удалить заметку. После удаления
notes.txtостанутся два файла. Сброс возвращает учебный набор; в обычном приложении наличие корзины или восстановления нужно проектировать отдельно. - Записать через поток. Посмотрите на ресурс PHP и
finallyв исходнике. Браузер моделирует результат маленького CSV; экономию памяти подтверждают настоящие потоковые операции проекта, а не эта схема состояния. - Разобрать ошибку. Чтение фиксированного отсутствующего файла покажет ожидаемый класс
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 — пользовательский интерфейс выбора и загрузки файлов.


