league/flysystem-aws-s3でS3をローカルストレージのように扱う
概要
ファイルの保存先を「ローカルディスク」から「Amazon S3」へ移す場面はよくあります。素朴にやると、ファイル操作のコードを AWS SDK の呼び出しに全部書き換えることになり、保存先を切り替えるたびにアプリ側を触ることになります。
PHPのストレージ抽象化ライブラリ league/flysystem を使うと、ローカル・S3・メモリなどのストレージを同じインターフェースで扱えます。aws-s3-v3 アダプタを使えば、アプリ側は「読む・書く・消す」だけを知っていればよく、実体がローカルかS3かを意識せずに済みます。この記事ではその手順をハンズオン形式でまとめます。
環境
- PHP 8.1 以上
- league/flysystem 3.x
- league/flysystem-aws-s3-v3 3.x
- aws/aws-sdk-php 3.x
composer require league/flysystem-aws-s3-v3
aws/aws-sdk-php はアダプタの依存として同時に入ります。
発生する問題
保存先をローカルからS3へ移すとき、次の課題が出ます。
- アプリ全体に散らばったファイル操作を書き換える必要がある
- テストはローカル、本番はS3、といった環境差を扱いたい
- ディレクトリの存在確認や一覧取得の挙動がローカルとS3で違う
- 大きなファイルをメモリに乗せずに扱いたい
原因
ローカルファイルシステムとS3は前提が違います。
- S3には本当のディレクトリが存在しない(キーの接頭辞をディレクトリのように見せているだけ)
- パーミッションの概念が異なる(S3はACL / バケットポリシー / visibility)
- ネットワーク越しなので、都度アクセスにレイテンシとコストがかかる
この差を毎回アプリ側で吸収するのは非効率です。Flysystem は「共通インターフェース + アダプタ」でこの差を吸収します。
対応方法
Flysystem の中心は League\Flysystem\Filesystem クラスです。ここに「どのアダプタを使うか」を注入し、アプリ側は Filesystem のメソッドだけを呼びます。
S3アダプタのセットアップ
<?php
use Aws\S3\S3Client;
use League\Flysystem\Filesystem;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
$client = new S3Client([
'region' => 'ap-northeast-1',
'version' => 'latest',
// EC2/ECS/LambdaならIAMロールが自動で使われる
// 認証情報はコードに書かない
]);
$adapter = new AwsS3V3Adapter($client, 'your-bucket-name', 'uploads');
$filesystem = new Filesystem($adapter);
第3引数の 'uploads' はプレフィックス(ルートとして扱うパス)です。以降のパスはこの接頭辞配下に置かれます。
> 認証情報(アクセスキー・シークレット)はコードにもリポジトリにも書かないでください。EC2/ECS/Lambda ではIAMロール、ローカルでは環境変数から解決させるのが安全です。
ローカルアダプタとの差はここだけ
use League\Flysystem\Local\LocalFilesystemAdapter;
$adapter = new LocalFilesystemAdapter('/var/app/storage');
$filesystem = new Filesystem($adapter);
アダプタを差し替えるだけで、以降の操作コードは一切変わりません。ここがFlysystemの肝です。
実装例
書き込み・読み込み・削除
$filesystem->write('reports/2026-09.txt', 'monthly report body');
$contents = $filesystem->read('reports/2026-09.txt');
if ($filesystem->fileExists('reports/2026-09.txt')) {
// ...
}
$filesystem->delete('reports/2026-09.txt');
大きなファイルはストリームで扱う
$stream = fopen($_FILES['upload']['tmp_name'], 'rb');
$filesystem->writeStream('videos/sample.mp4', $stream);
if (is_resource($stream)) {
fclose($stream);
}
$readStream = $filesystem->readStream('videos/sample.mp4');
S3アダプタは内部でマルチパートアップロードを扱えるため、大容量ファイルもストリームで安定して転送できます。
一覧取得
use League\Flysystem\StorageAttributes;
$paths = $filesystem->listContents('reports', deep: true)
->filter(fn (StorageAttributes $item) => $item->isFile())
->map(fn (StorageAttributes $item) => $item->path())
->toArray();
S3では「ディレクトリ」は擬似的なので、isFile() でファイルだけに絞ると扱いやすくなります。
公開範囲(visibility)
use League\Flysystem\Visibility;
$filesystem->write('public/logo.png', $binary, [
'visibility' => Visibility::PUBLIC,
]);
PUBLIC を指定するとオブジェクトが公開ACLで書き込まれますが、バケットのブロックパブリックアクセス設定と衝突すると失敗します。公開配布は署名付きURLで行う方針が無難です。
署名付きURL(一時URL)
$url = $adapter->temporaryUrl(
'reports/2026-09.txt',
new DateTimeImmutable('+15 minutes'),
[]
);
これはローカルアダプタには無いS3固有機能です。共通インターフェースで大半を書きつつ、必要な場面だけアダプタ固有機能を使う設計にできます。
確認方法
ユニットテストではインメモリアダプタを使い、S3に接続せずに検証できます。
composer require --dev league/flysystem-memory
use League\Flysystem\Filesystem;
use League\Flysystem\InMemory\InMemoryFilesystemAdapter;
$filesystem = new Filesystem(new InMemoryFilesystemAdapter());
$filesystem->write('a.txt', 'hello');
assert($filesystem->read('a.txt') === 'hello');
実接続の確認は AWS CLI でオブジェクトの有無を確認します。
aws s3 ls s3://your-bucket-name/uploads/reports/ --profile your-profile
注意点
- ディレクトリはS3に存在しない。空ディレクトリ前提の設計は避ける
fileExists()はネットワークアクセス。ループ内で乱発するとコスト増- リージョンとバケットは一致させる(不一致は分かりにくいエラーになる)
- 認証情報を直書きしない(IAMロール・環境変数で解決)
Visibility::PUBLICはブロックパブリックアクセスと衝突すると失敗する
まとめ
- Flysystem は「共通インターフェース + アダプタ」でストレージの実体を隠す
AwsS3V3Adapterに差し替えるだけでアプリコードを変えずにS3化できる- 大容量はストリーム、一時公開は署名付きURL
- テストはインメモリ、本番はS3、という切り替えが自然にできる
- S3固有の前提(ディレクトリ非実在・ネットワークコスト)だけは意識する
保存先を抽象化しておくと、「まずローカルで作る → あとでS3へ移す」という段階的な移行がとても楽になります。
