ファイルストレージ — 商品画像と領収書 PDF
ここまでの 2 章は、重い処理を後ろへ回し、同じクエリを繰り返さないようにする話でした。扱ってきたデータは、どれもデータベースの行でした。
この章の相手は違います。商品画像と領収書 PDF、つまりデータベースに入れないファイルです。
ファイルは 2 つの軸で分かれます。どこから来るかと、誰が見てよいかです。商品画像は外から届いて誰が見てもよく、領収書はこちらで作って本人だけが見られます。この 2 つを並べて作ると、設計がどこで分かれるのかが見えます。
最後に、ファイルを扱うときだけ現れる問題を扱います。DB::transaction() はファイルを巻き戻しません。 第14章で手に入れた道具が効かない、唯一の場所です。
画像アップロードのテストが GD 拡張を要求します。UploadedFile::fake()->image() が本物の画像ファイルを生成するためです。php -m | grep gd で確認してください。
PDF を作る dompdf も Requirements に GD を挙げています。領収書の HTML に画像を入れないなら要らないはずですが、それは確認していません。入れておくのが安全です。
ディスクという抽象
設定は config/filesystems.php にあります。初期状態で 3 つのディスクが定義されています。
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
// ... 認証情報とバケット名 ...
],
],
local と public は同じドライバです。 違うのは置き場所と、外から読めるかどうかだけです。
| ディスク | 置き場所 | URL で読めるか |
|---|---|---|
local | storage/app/private | 読めない |
public | storage/app/public | storage:link を張れば読める |
s3 | S3 バケット | バケットの設定で決まる |
この表がこの章の設計そのものです。 ファイルを置くときに決めるのは置き場所ではなく、「誰に見せるか」です。
使うときはディスク名を指定します。
Storage::disk('public')->put('a.txt', $contents);
省略すると .env の FILESYSTEM_DISK (既定は local) が使われます。省略しないでください。 既定値が変わった日に、非公開のファイルが公開ディスクへ移ります。しかもエラーは出ません。
storage:link を張る
public ディスクのファイルを HTTP で読めるようにするには、シンボリックリンクが要ります。
php artisan storage:link
public/storage から storage/app/public へのリンクができます。リンクの定義は設定ファイル側にあります。
'links' => [
public_path('storage') => storage_path('app/public'),
],
このコマンドをデプロイ手順に入れてください。 リンクはリポジトリに入らないので、新しいサーバーには存在しません。第21章の手順に含めます。
なぜ public/ に直接置かないか
アップロードされたファイルを public/uploads/ に置いても動きます。それでも storage/ を経由するのには理由が 3 つあります。
デプロイで消えます。 リリースごとに新しいディレクトリへ展開して切り替える方式では、public/ の中身も入れ替わります。前のリリースに置いたファイルは、新しい方にはありません。
コードと成果物が混ざります。 どれがリポジトリ由来で、どれが実行時に増えたものかが、ディレクトリを見ても分かりません。バックアップの対象を決めるときに困ります。
S3 へ移せません。 Storage を通していれば、設定を変えるだけで置き場所が移ります。public/ へ直接書くコードは、書き換えが要ります。
書き込みの失敗は既定では例外になりません
throw が false なので、書き込めなかったことは戻り値でしか分かりません。
if (! Storage::disk('public')->put($path, $contents)) {
// 書き込めなかった
}
ディスクが満杯でも、権限が無くても、例外は飛びません。 戻り値を見ないコードは「保存したつもり」で先へ進み、存在しないファイルを指すパスだけがデータベースに残ります。
このガイドは throw を true にします。
'local' => [
// ...
'throw' => true, // ← 変更
],
'public' => [
// ...
'throw' => true, // ← 変更
],
失敗すると League\Flysystem\UnableToWriteFile が飛び、第9章で作った例外ハンドラが 500 と追跡 ID を返します。書き込みのたびに戻り値を確認するより、確認を忘れられない形にするほうが確実です。 この章のコードが put() や store() の戻り値を見ていないのは、この設定を前提にしています。
アップロードを受け取る
商品画像を受け取ります。第1章のエンドポイント一覧にあった POST /api/v1/products/{product}/image です。
列を足す
パスを保存する列が要ります。
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->string('image_path')->nullable();
});
}
同じ作業として #[Fillable] を直します。
#[Fillable(['name', 'description', 'price', 'image_path'])]
第14章で「移行ファイルだけ作って属性を足し忘れる形が起こりやすい」と書き、第17章でも同じ組を扱いました。列と属性は 1 組です。 忘れると update() が値を黙って捨てるので、アップロードは 200 を返すのに画像は出ません。
検証する
形式の検証は Form Request でやります。第5章で決めた置き場所です。
php artisan make:request UploadProductImageRequest
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\File;
public function rules(): array
{
return [
'image' => [
'required',
File::image()
->max('2mb')
->dimensions(Rule::dimensions()->maxWidth(2000)->maxHeight(2000)),
],
];
}
File::image() は jpg・jpeg・png・bmp・gif・webp を通します。サイズの単位は文字列で書けます。kb / mb / gb / tb が使えます。数値だけを渡すとキロバイト単位になるので、単位を書いたほうが読み間違えません。
dimensions で縦横の上限も入れています。容量だけを見ていると、1 MB でも 20000 × 20000 ピクセルの画像が通ります。これを加工しようとした時点でメモリが尽きます。
File::image() は SVG を弾きます。意図的な既定値です。
SVG は XML なので、中に <script> を書けます。ブラウザが画像として開くと、そのスクリプトが動きます。画像のアップロード欄が XSS の入口になります。
File::image(allowSvg: true) で許可できますが、許可するなら別の防御が要ります。配信するドメインをアプリケーションと分ける、Content-Security-Policy を付ける、といった対策です。この章では許可しません。
File::image() や mimes:png は、ファイルの中身を読んで MIME タイプを推定します。名前は見ません。
extensions:jpg,png は逆です。利用者が付けた拡張子だけを見て、中身は見ません。
photo.txt という名前で PNG の中身を送ると、mimes:png は通り、extensions:png は落ちます。公式ドキュメントも、拡張子だけで判断しないよう明記しています。両方を組み合わせて初めて「名前と中身が一致した画像」になります。
保存する
<?php
namespace App\Http\Controllers;
use App\Http\Requests\UploadProductImageRequest;
use App\Http\Resources\ProductResource;
use App\Models\Product;
class ProductImageController extends Controller
{
public function store(UploadProductImageRequest $request, Product $product)
{
$path = $request->file('image')->store('products', 'public');
$product->update(['image_path' => $path]);
return new ProductResource($product);
}
}
store() の第 1 引数はディレクトリ、第 2 引数はディスクです。ファイル名は指定していません。 Laravel が一意な名前を作り、拡張子は MIME タイプから決めます。返るのはディスクの root からの相対パスで、これを列に入れます。
getClientOriginalName() で元のファイル名を取れますが、公式ドキュメントは unsafe と明記しています。
同じ名前で送れば前のファイルを上書きできます。名前に ../ を混ぜられます。日本語やスペースが入った名前は、URL として壊れます。
名前を自分で決めたいときは hashName() (ランダムな一意名) と extension() (中身から判定した拡張子) を使ってください。
誰が差し替えてよいか
ここで問題が出ます。このエンドポイントは誰が叩けるべきでしょうか。
第12章で作った Policy は使えません。Policy は「このユーザーがこのモデルに対して」を判断する仕組みなので、判断の材料になる所有関係が要ります。注文には注文した人がいますが、商品には所有者がいません。
このガイドは管理機能を作らないので、管理者という役割もありません。使えるのは、第11章で扱った ability です。
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'webhook.payment' => VerifyPaymentWebhookSignature::class,
'abilities' => CheckAbilities::class, // ← 追加
'ability' => CheckForAnyAbility::class, // ← 追加
]);
})
alias の登録が要ります。 第13章で webhook.payment を登録したのと同じ場所です。登録せずにルートへ書くと、ミドルウェアが見つからず例外になります。
Route::post('/products/{product}/image', [ProductImageController::class, 'store'])
->middleware(['auth:sanctum', 'abilities:products:write']);
第11章の登録とログインは、ability を明示してトークンを発行しています。
'token' => $user->createToken('api', ['order:create', 'order:read'])->plainTextToken,
この第 2 引数を省略すると ['*'] になります。 すべての ability を持つ、という意味です。省略した状態では abilities:products:write が登録済みの全員を通します。守っているつもりで、何も守れていません。
第11章であの 2 箇所を絞ったのは、この章のためです。顧客のトークンが products:write を持たないことが、このエンドポイントを守っている実体です。 ミドルウェアは、絞られた結果を確認しているだけです。
自分のコードで createToken() の第 2 引数が省略されていないか、ここで一度確認してください。
商品画像を差し替えるトークンは別に発行します。管理画面を作らないので、手作業です。
php artisan tinker
User::find(1)->createToken('product-admin', ['products:write'])->plainTextToken;
表示された文字列はその場でしか見られません。第11章で扱ったとおりです。
abilities:order:create を注文のルートにも付けられます。この章では付けません。
目的は「顧客のトークンで商品画像を差し替えられないこと」で、それは発行側を絞った時点で達成されています。確認を掛けるのは、絞りたいルートだけで足ります。全ルートに付けると、ability を 1 つ増やすたびに全ルートを見直すことになります。
第11章に書いたとおり、ability と Policy は別の層です。ability は「このトークンで何ができるか」、Policy は「このユーザーがこのデータに対して何ができるか」を決めます。この章は前者だけを使います。
ability が足りないとき、Sanctum は MissingAbilityException を投げます。これは AuthorizationException を継承しているので、第9章の例外ハンドラが除外している型に入ります。403 で返ります。あのとき除外リストに AuthorizationException を書いていなければ、ここが 500 になっていました。
レスポンスに URL を出す
パスをそのまま返しても、クライアントは画像を表示できません。URL に変換します。
use Illuminate\Support\Facades\Storage;
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'price' => $this->price,
'image_url' => $this->image_path
? Storage::disk('public')->url($this->image_path)
: null,
'stock_quantity' => $this->whenLoaded('stock', fn () => $this->stock?->quantity ?? 0),
];
}
url() は public ディスクなら /storage/products/xxxx.jpg を返し、S3 なら完全な URL を返します。呼び出し側のコードは変わりません。
画像が無い商品には null を返します。whenLoaded のようにキーを消す形は採りません。キーが消えると、クライアントは「画像が無い」と「実装が変わった」を区別できません。 リレーションの有無 (第7章) と、値の有無は別の話です。
第18章でキャッシュした商品一覧にも、この image_url が乗ります。image_path は列なので、キャッシュされたモデルからも読めます。画像を差し替えると ProductObserver::saved が発火してキャッシュが失効します。 第18章で無効化をオブザーバへ寄せたのは、こうやって更新の経路が後から増えるからです。
Laravel には画像を加工する API があります (intervention/image の追加が要ります)。公式ドキュメントは「画像処理は CPU とメモリを食うので、キュー済みジョブでやること」と警告しています。 リクエストの中でリサイズを走らせると、第16章で外へ出した重い処理を別の形で戻すことになります。この章では加工しません。
非公開のファイルを渡す
領収書は本人だけが見られます。置き場所も渡し方も、商品画像とは変わります。
置き場所
local ディスク (storage/app/private) に置きます。public に置いてはいけません。
URL を知っていれば誰でも読めるからです。注文 ID が連番なら、receipts/1.pdf から順に試せます。認可のコードを 1 行も通らずに、他人の領収書が読めます。
第12章で 404 と 403 を使い分けたのも、第13章で ID の列挙を扱ったのも、この形を避けるためでした。置き場所を間違えると、その全部が無効になります。
渡し方は 2 つある
アプリケーションを通す形は、ファイルの中身をコントローラから返します。
return Storage::disk('local')->download($order->receipt_path, "receipt-{$order->id}.pdf");
リクエストがアプリケーションに届くので、認可のコードが必ず走ります。
署名付き URL を返す形は、期限付きの URL だけを返します。
$url = Storage::disk('local')->temporaryUrl(
$order->receipt_path, now()->plus(minutes: 5)
);
ダウンロードそのものはアプリケーションを通りません。S3 なら S3 が直接返します。
| アプリケーションを通す | 署名付き URL | |
|---|---|---|
| 認可 | リクエストごとに判断できる | 発行時の 1 回だけ |
| 転送 | アプリケーションを経由する | S3 なら直接 |
| URL の漏洩 | 認可で止まる | 期限内は誰でも読める |
| 取り消し | すぐ効く | 期限が切れるまで効かない |
temporaryUrl は local と s3 で使えます。local で動くのは、初期設定に 'serve' => true が入っているからです。古い記事が「local では使えない」と書いているのは、この設定が無かった頃の話です。
このガイドの選択
アプリケーションを通す形にします。第1章が「領収書 PDF のダウンロード」と約束したエンドポイントで、ファイルは数十 KB です。転送を節約する理由がありません。
署名付き URL へ切り替える条件は 2 つあります。ファイルが大きいときは、転送のあいだアプリケーションのプロセスが占有されます。S3 を使うときは、アプリケーションを経由すると S3 から取得してから返すので、往復が 2 倍になります。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ReceiptController extends Controller
{
public function show(Request $request, int $orderId)
{
$order = $request->user()->orders()->findOrFail($orderId);
abort_if($order->receipt_path === null, 404);
return Storage::disk('local')->download(
$order->receipt_path,
"receipt-{$order->id}.pdf",
);
}
}
Route::middleware('auth:sanctum')->group(function () {
// ... 既存のルート ...
Route::get('/orders/{order}/receipt', [ReceiptController::class, 'show']); // ← 追加
});
第12章と同じ形で絞っています。 $request->user()->orders() から探すので、他人の注文は「存在しない」扱いで 404 になります。Gate::authorize('view', $order) を使うと 403 になり、第12章で決めた「注文の存在は隠す」と食い違います。
生成前の領収書も 404 です。 第4章のステータスコード表の「ID に対応するものがない」に収まります。入金前に叩かれるのは異常ではなく、正常な状態のひとつです。
生成物をジョブで作る
いつ作るか
領収書は入金が確認できてから作ります。その瞬間を知っているのは、第17章の RecordPayment です。
public function handle(): void
{
$payment = Payment::firstOrCreate(
['provider_event_id' => $this->eventId],
['order_id' => $this->orderId, 'amount' => $this->amount],
);
if (! $payment->wasRecentlyCreated) {
return;
}
NotifyWarehouse::dispatch($payment->order);
GenerateReceipt::dispatch($payment->order); // ← 追加
}
早期 return の下に置いています。 第17章で作った構造がそのまま効きます。同じ通知が 2 回届いても、領収書の生成は 1 回だけです。
PaymentRecorded イベントを発行してリスナーで受ける形にもできます。第17章でも触れた選択です。受け手が 1 つのうちは、ジョブから直接積むほうが追いやすいです。 3 つ目が増えたときに、イベントへ切り替える判断になります。
何で作るか
PDF の生成は Laravel の機能ではありません。ライブラリを入れます。
composer require barryvdh/laravel-dompdf
<?php
namespace App\Jobs;
use App\Models\Order;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Storage;
class GenerateReceipt implements ShouldQueue
{
use Queueable;
public function __construct(public readonly Order $order) {}
public function handle(): void
{
$path = "receipts/{$this->order->id}.pdf";
Storage::disk('local')->put($path, Pdf::loadHTML($this->html())->output());
$this->order->update(['receipt_path' => $path]);
}
private function html(): string
{
$font = storage_path('fonts/NotoSansJP-Regular.ttf');
$amount = number_format($this->order->total_amount);
return <<<HTML
<html>
<head>
<meta charset="utf-8">
<style>
@font-face {
font-family: 'NotoSansJP';
src: url('{$font}') format('truetype');
}
body { font-family: 'NotoSansJP', sans-serif; }
</style>
</head>
<body>
<h1>領収書</h1>
<p>注文番号: {$this->order->id}</p>
<p>金額: {$amount} 円</p>
</body>
</html>
HTML;
}
}
output() が PDF のバイト列を返します。 ファイルに書くのは Storage の仕事なので、ライブラリにファイル操作をさせません。置き場所の判断が 1 か所に集まります。
loadView() で Blade テンプレートを渡す形もあります。ここでは使いません。体裁を作り込むのはこの章の主題ではないので、HTML はヒアドキュメントで最小限にしています。
spatie/laravel-pdf という選択肢もあります。こちらはヘッドレスブラウザで描画するので CSS の再現度が高い代わりに、Node.js の環境が要ります。
列も足します。忘れると update() がパスを黙って捨てて、ダウンロードが永久に 404 になります。
$table->string('receipt_path')->nullable();
#[Fillable([
'user_id', 'customer_email', 'status', 'total_amount',
'idempotency_key', 'confirmation_sent_at', 'shipment_status', 'receipt_path',
])]
日本語のフォントが要ります
上のコードで @font-face を書いたのには理由があります。用意しないと、文字が出ません。
PDF が内部に持っているフォント (Helvetica・Times-Roman・Courier ほか) は、Windows ANSI の文字しか持っていません。dompdf に同梱されている DejaVu も、対応するのはラテン文字・ギリシャ文字・キリル文字・アラビア文字などで、漢字と仮名は入っていません。
フォントを置きます。
mkdir -p storage/fonts
cp ~/Downloads/NotoSansJP-Regular.ttf storage/fonts/
dompdf は @font-face で参照されたフォントを読み込み、storage/fonts にキャッシュして PDF へ埋め込みます。この置き場所は fontDir 設定で決まります。既定が storage/fonts なので、変えないかぎり設定ファイルは要りません。変えるなら php artisan vendor:publish で config/dompdf.php を出します。
PDF に埋め込むと、フォントのデータが配布物に入ります。埋め込みと再配布を許可していないフォントは使えません。
Noto Sans CJK は SIL Open Font License 1.1 で、埋め込みが認められています。一方、OS に付属するフォントをコピーして配る形は、たいてい許可されていません。
この問題はテストで見つかりません。 フォントが無くても PDF の生成は成功し、ファイルもできます。中の文字が抜けるだけです。一度は目で開いて確認してください。
パスを安定させる
パスは receipts/{注文 ID}.pdf にしました。注文 1 件につき 1 か所です。
ジョブが 2 回実行されても、同じ場所へ同じ内容を書くだけです。第17章で「同じ値を書く操作は、何度やっても結果が変わらない」と書いたのがこれです。ファイル名にランダムな値を使うと、実行した回数だけファイルが増えます。
第18章では逆の判断をしました。キャッシュのバージョンには UUID を使い、毎回必ず違う値になるようにしました。目的が逆だからです。あちらは「変わったことを伝えたい」、こちらは「同じ場所を指し続けたい」。
商品画像はさらに逆で、ランダムな名前のままにしました。 products/{商品 ID}.jpg に固定すれば古いファイルを消す手間は消えますが、URL が変わらなくなります。CDN とブラウザは同じ URL の中身が変わったことを知らないので、差し替えても古い画像を返し続けます。孤児のファイルを掃除するほうが、古い画像が消えない問題よりも扱いやすいです。
判断の軸は 1 つです。URL として外に出るものは名前を変え、外に出ないものは名前を固定します。
第15章で attachFromStorage に触れました。Mailable なら、ストレージ上のファイルを添付できます。
ただし添付できるのは入金確認の通知です。第15章で作った注文確認メールは、注文した瞬間に飛びます。その時点で receipt_path はまだ null なので、注文確認メールに領収書は付けられません。
ファイルとレコードがずれる
保存と配信ができました。最後に、ファイルを扱うときだけ現れる問題を扱います。
トランザクションはファイルを戻しません
DB::transaction(function () use ($request, $product) {
$path = $request->file('image')->store('products', 'public');
$product->update(['image_path' => $path]);
// ここで例外が飛ぶと
});
行の更新は戻りますが、ファイルは残ります。 DB::transaction() が管理しているのはデータベース接続だけです。第14章で在庫の二重引き当てを塞いだ道具は、ここでは効きません。
これは不具合ではありません。そういうものだと受け入れて設計する話です。
どちらへ倒すか
ファイルと行の書き込みは、必ずどちらかが先になります。途中で失敗したときに何が残るかが変わります。
ファイルを先に書くと、行が更新されなければ、誰からも参照されないファイルが残ります。ディスクを少し食いますが、利用者には何も起きません。あとから消せます。
行を先に更新すると、ファイルの書き込みが失敗したときに、存在しないファイルを指すパスが残ります。一覧を開いた利用者に、壊れた画像が出ます。
ファイルを先に書きます。 孤児のファイルは静かな問題で、壊れたリンクは見える問題です。静かなほうへ倒します。
孤児のファイルを掃除する仕組みは、次章のスケジューラで作ります。
差し替えたら古いのを消す
store() は毎回違うファイル名を作ります。画像を 10 回差し替えれば、10 個のファイルが残ります。
use Illuminate\Support\Facades\Storage; // ← 追加
public function store(UploadProductImageRequest $request, Product $product)
{
$previous = $product->image_path;
$path = $request->file('image')->store('products', 'public');
$product->update(['image_path' => $path]);
if ($previous !== null) {
Storage::disk('public')->delete($previous);
}
return new ProductResource($product);
}
削除は行の更新より後です。 先に消すと、更新が失敗したときに「行は古いパスを指しているのに、そのファイルが無い」状態になります。上で決めた「静かなほうへ倒す」と同じ判断です。
削除に追随する
商品が削除されたら、画像も消します。第18章で作ったオブザーバに足します。
use Illuminate\Support\Facades\Storage; // ← 追加
public function deleted(Product $product): void
{
Cache::forever('products.version', (string) Str::uuid());
if ($product->image_path !== null) {
Storage::disk('public')->delete($product->image_path);
}
}
キャッシュのバージョン更新は消しません。 第18章で入れた 1 行が、そのまま要ります。
第18章で扱ったとおり、Product::where(...)->delete() のような一括削除ではモデルイベントが発行されません。オブザーバは呼ばれず、ファイルが残ります。
オブザーバは網羅的な対策ではありません。 取りこぼしを前提に、孤児のファイルを定期的に掃除する仕組みを別に持ちます。次章で作ります。
テスト
第10章で予告した Storage::fake() を使います。
use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
pest()->use(RefreshDatabase::class);
test('商品画像をアップロードできる', function () {
Storage::fake('public');
$product = Product::factory()->create();
Sanctum::actingAs(User::factory()->create(), ['products:write']);
$this->postJson("/api/v1/products/{$product->id}/image", [
'image' => UploadedFile::fake()->image('sample.jpg'),
])->assertStatus(200);
$path = $product->fresh()->image_path;
expect($path)->not->toBeNull();
Storage::disk('public')->assertExists($path);
});
test('abilityの無いトークンでは差し替えられない', function () {
Storage::fake('public');
$product = Product::factory()->create();
Sanctum::actingAs(User::factory()->create()); // ability を渡さない
$this->postJson("/api/v1/products/{$product->id}/image", [
'image' => UploadedFile::fake()->image('sample.jpg'),
])->assertStatus(403);
Storage::disk('public')->assertEmpty();
});
Storage::fake('public') は、そのディスクをテスト用の一時ディレクトリへ差し替えます。実際の storage/app/public は汚れません。 テストが終われば中身も消えます。
Sanctum::actingAs() の第 2 引数が ability です。省略すると空の配列になり、abilities:products:write を通りません。だから 2 つ目のテストが成立します。
UploadedFile::fake()->image() は、指定したサイズの画像を実際に生成します。この生成に GD 拡張が要ります。
2 つ目のテストがこの章の要です。 1 つ目だけなら、ミドルウェアを外しても green のままです。「通るべきものが通る」テストは、認可が壊れていても通ります。 第12章で確認したのと同じ構造です。
領収書も同じ形で確認します。
use App\Jobs\GenerateReceipt;
use App\Models\Order;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
pest()->use(RefreshDatabase::class);
test('自分の領収書はダウンロードできる', function () {
Storage::fake('local');
$user = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user->id]);
GenerateReceipt::dispatchSync($order);
Sanctum::actingAs($user);
$this->get("/api/v1/orders/{$order->id}/receipt")
->assertDownload("receipt-{$order->id}.pdf");
});
test('他人の領収書は404になる', function () {
Storage::fake('local');
$order = Order::factory()->create(['user_id' => User::factory()->create()->id]);
GenerateReceipt::dispatchSync($order);
Sanctum::actingAs(User::factory()->create()); // 別の利用者
$this->getJson("/api/v1/orders/{$order->id}/receipt")->assertStatus(404);
});
test('生成前の領収書は404になる', function () {
$user = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user->id]);
Sanctum::actingAs($user);
$this->getJson("/api/v1/orders/{$order->id}/receipt")->assertStatus(404);
});
dispatchSync() はジョブをその場で実行します。キューを介さないので、生成した直後の状態を確認できます。 ここでは dompdf も実際に動くので、PDF の生成そのものが壊れていれば落ちます。
第17章のテストにも 1 行足します。あちらは「同じ通知が 2 回来ても出荷指示は 1 回だけ」を固定していました。領収書もまったく同じ性質を持ちます。
use App\Jobs\GenerateReceipt; // ← 追加
// ... test の中 ...
$this->assertDatabaseCount('payments', 1);
Queue::assertPushed(NotifyWarehouse::class, 1);
Queue::assertPushed(GenerateReceipt::class, 1); // ← 追加
この 1 行が無いと、RecordPayment から GenerateReceipt::dispatch() を消してもテストは全部通ります。 領収書が作られなくなるだけで、他には何も起きません。足した配線には、それが消えたら落ちるテストを付けてください。
それでも検出できないものが 2 つあります。
フォントの設定です。 フォントを置いていない PDF も、生成には成功してファイルもできます。assertDownload も通ります。文字が入っているかどうかは、テストからは見えません。
throw の設定です。 これは config/filesystems.php の値なので、false に戻してもこの章のテストは全部 green のままです。書き込みが失敗する状況をテストで作るのは、それ自体が難しいためです。
config('filesystems.disks.public.throw') を見る 1 行の assert なら書けます。ただしそれは振る舞いではなく設定値を固定するテストで、第17章でリトライ回数の固定を「割に合わない」と書いたのと同じ判断が要ります。テストで守れないというより、守るコストの置き場所が変わります。 どちらを選ぶにしても、第9章のエラー設計と同じ組で見る項目です。
まとめ
- ディスクを選ぶことが「誰に見せるか」を決めること。
localは非公開、publicはstorage:linkで公開 - ディスク名を省略しない。既定値が変わると非公開のファイルが公開側へ移る
public/へ直接置かない。デプロイで消え、S3 へ移せなくなるthrowは既定でfalseで、書き込みの失敗が戻り値でしか分からない。trueにして例外へ寄せる- 列とモデルの
#[Fillable]は 1 組。忘れるとパスが黙って捨てられる File::image()は SVG を弾く。XML の中にスクリプトを書けるためmimesは中身、extensionsは名前を見る。別のものを見ている- 送信側が付けたファイル名を使わない。
hashName()とextension()を使う - 所有者のいないリソースに Policy は使えない。Sanctum の ability で絞る
- ability を指定しないトークンは
['*']を持つ。発行側を絞らないと ability の確認は無意味 - 非公開ファイルの渡し方は 2 つ。署名付き URL は発行時の 1 回しか認可を見ない
- URL として外に出るファイルは名前を変え、外に出ないファイルは名前を固定する。前者は CDN の都合、後者は 2 回実行しても増えない性質のため
- 日本語の PDF はフォントを自分で用意する。無くても生成は成功し、文字だけが消える
DB::transaction()はファイルを巻き戻さない。孤児のファイル側へ倒す- 一括削除ではオブザーバが発火しない。定期的な掃除を別に持つ
次に読む
次章はタスクスケジューラです。定期的に走らせる処理を扱います。この章で 2 回「次章で掃除します」と書いた孤児のファイルが、最初の題材です。未決済の注文を自動でキャンセルする処理も、ここで作ります。cron に登録するのは 1 行だけで、あとは Laravel 側で管理する形になります。
練習問題
次の領収書エンドポイントには重大な問題があります。指摘してください
Route::get('/orders/{order}/receipt', function (Order $order) {
return response()->json([
'url' => Storage::disk('local')->temporaryUrl(
$order->receipt_path, now()->plus(hours: 24)
),
]);
})->middleware('auth:sanctum');
解答例
誰の注文かを確認していません。
ルートモデルバインディングは、URL の ID からモデルを引いてくるだけです。それが誰の注文かは見ていません。 ログインしていれば、/api/v1/orders/1/receipt から順に叩いて全員の領収書 URL を集められます。
第12章で決めたとおり、$request->user()->orders()->findOrFail($orderId) の形にします。自分の注文の中から探すので、他人の注文は 404 になります。
問題はもう 2 つあります。
有効期限が 24 時間です。 発行した URL は、その 24 時間のあいだ認可を通らずに読めます。URL がチャットに貼られれば、貼られた先の全員が読めます。領収書には氏名と購入内容が入っているので、これは個人情報の漏洩です。期限は「利用者がダウンロードを終えるまで」で足ります。数分です。
receipt_path が null の場合を見ていません。 入金前の注文を叩くと、null を渡して URL を作ろうとします。通れば何も無い場所を指す URL が返り、失敗すれば第9章の例外ハンドラに落ちて 500 になります。どちらも「まだ発行されていない」を伝えていません。 本文の実装が abort_if で 404 を返しているのは、この状態を正常な応答のひとつとして扱うためです。
「画像アップロードのテストは green なのに、本番で画像が表示されない」と報告されました。何から確認しますか
解答例
テストが green なので、テストが通る範囲の外に原因があります。Storage::fake() は本物のディスクを触らないので、置き場所と配信に関わる問題はすべてこの外側です。
1 つ目は storage:link です。 このリンクはリポジトリに入りません。新しいサーバーへデプロイしたとき、コマンドを実行していなければ public/storage が存在しません。image_url は /storage/... を返し続けるので、API のレスポンスは正しく、画像だけが 404 になります。デプロイ手順に入っていたかを確認します。
2 つ目は書き込みの権限です。 storage/app/public に Web サーバーのユーザーが書けなければ、保存は失敗します。この章で throw を true にしたので、500 と追跡 ID が返り、ログに残ります。まずログを確認します。第17章で扱ったように、ワーカーを root で動かして所有者がずれている場合も同じ症状です。
throw を既定の false のままにしていると、この原因はいちばん見つけにくくなります。 put() が false を返すだけなので、アップロードは 200 を返し、image_path には値が入り、ファイルだけが無い状態になります。設定を 1 つ変えておくかどうかで、調査にかかる時間が変わります。
3 つ目は FILESYSTEM_DISK です。 どこかで Storage::disk('public') の 'public' を省略していれば、既定のディスクへ書かれます。既定は local なので、ファイルは storage/app/private に置かれます。URL からは絶対に読めない場所です。
確認の順番は、症状で切り分けられます。ファイルが storage/app/public にあるなら 1 つ目、無いなら 2 つ目か 3 つ目です。どちらも「アップロードは成功したように見える」ので、レスポンスを見ているだけでは区別できません。実際のディレクトリを見にいってください。