Skip to main content

ファイルストレージ — 商品画像と領収書 PDF

ここまでの 2 章は、重い処理を後ろへ回し、同じクエリを繰り返さないようにする話でした。扱ってきたデータは、どれもデータベースの行でした。

この章の相手は違います。商品画像と領収書 PDF、つまりデータベースに入れないファイルです。

ファイルは 2 つの軸で分かれます。どこから来るかと、誰が見てよいかです。商品画像は外から届いて誰が見てもよく、領収書はこちらで作って本人だけが見られます。この 2 つを並べて作ると、設計がどこで分かれるのかが見えます。

最後に、ファイルを扱うときだけ現れる問題を扱います。DB::transaction() はファイルを巻き戻しません。 第14章で手に入れた道具が効かない、唯一の場所です。

この章で必要な PHP 拡張

画像アップロードのテストが GD 拡張を要求します。UploadedFile::fake()->image() が本物の画像ファイルを生成するためです。php -m | grep gd で確認してください。

PDF を作る dompdf も Requirements に GD を挙げています。領収書の HTML に画像を入れないなら要らないはずですが、それは確認していません。入れておくのが安全です。

ディスクという抽象

設定は config/filesystems.php にあります。初期状態で 3 つのディスクが定義されています。

config/filesystems.php (抜粋)
'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',
// ... 認証情報とバケット名 ...
],

],

localpublic は同じドライバです。 違うのは置き場所と、外から読めるかどうかだけです。

ディスク置き場所URL で読めるか
localstorage/app/private読めない
publicstorage/app/publicstorage:link を張れば読める
s3S3 バケットバケットの設定で決まる

この表がこの章の設計そのものです。 ファイルを置くときに決めるのは置き場所ではなく、「誰に見せるか」です。

使うときはディスク名を指定します。

Storage::disk('public')->put('a.txt', $contents);

省略すると .envFILESYSTEM_DISK (既定は local) が使われます。省略しないでください。 既定値が変わった日に、非公開のファイルが公開ディスクへ移ります。しかもエラーは出ません。

storage:link を張る

public ディスクのファイルを HTTP で読めるようにするには、シンボリックリンクが要ります。

php artisan storage:link

public/storage から storage/app/public へのリンクができます。リンクの定義は設定ファイル側にあります。

config/filesystems.php
'links' => [
public_path('storage') => storage_path('app/public'),
],

このコマンドをデプロイ手順に入れてください。 リンクはリポジトリに入らないので、新しいサーバーには存在しません。第21章の手順に含めます。

なぜ public/ に直接置かないか

アップロードされたファイルを public/uploads/ に置いても動きます。それでも storage/ を経由するのには理由が 3 つあります。

デプロイで消えます。 リリースごとに新しいディレクトリへ展開して切り替える方式では、public/ の中身も入れ替わります。前のリリースに置いたファイルは、新しい方にはありません。

コードと成果物が混ざります。 どれがリポジトリ由来で、どれが実行時に増えたものかが、ディレクトリを見ても分かりません。バックアップの対象を決めるときに困ります。

S3 へ移せません。 Storage を通していれば、設定を変えるだけで置き場所が移ります。public/ へ直接書くコードは、書き換えが要ります。

書き込みの失敗は既定では例外になりません

throwfalse なので、書き込めなかったことは戻り値でしか分かりません。

if (! Storage::disk('public')->put($path, $contents)) {
// 書き込めなかった
}

ディスクが満杯でも、権限が無くても、例外は飛びません。 戻り値を見ないコードは「保存したつもり」で先へ進み、存在しないファイルを指すパスだけがデータベースに残ります

このガイドは throwtrue にします。

config/filesystems.php
'local' => [
// ...
'throw' => true, // ← 変更
],

'public' => [
// ...
'throw' => true, // ← 変更
],

失敗すると League\Flysystem\UnableToWriteFile が飛び、第9章で作った例外ハンドラが 500 と追跡 ID を返します。書き込みのたびに戻り値を確認するより、確認を忘れられない形にするほうが確実です。 この章のコードが put()store() の戻り値を見ていないのは、この設定を前提にしています。

アップロードを受け取る

商品画像を受け取ります。第1章のエンドポイント一覧にあった POST /api/v1/products/{product}/image です。

列を足す

パスを保存する列が要ります。

database/migrations/xxxx_add_image_path_to_products_table.php
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->string('image_path')->nullable();
});
}

同じ作業として #[Fillable] を直します。

app/Models/Product.php
#[Fillable(['name', 'description', 'price', 'image_path'])]

第14章で「移行ファイルだけ作って属性を足し忘れる形が起こりやすい」と書き、第17章でも同じ組を扱いました。列と属性は 1 組です。 忘れると update() が値を黙って捨てるので、アップロードは 200 を返すのに画像は出ません。

検証する

形式の検証は Form Request でやります。第5章で決めた置き場所です。

php artisan make:request UploadProductImageRequest
app/Http/Requests/UploadProductImageRequest.php
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 ピクセルの画像が通ります。これを加工しようとした時点でメモリが尽きます。

SVG は既定で通りません

File::image() は SVG を弾きます。意図的な既定値です。

SVG は XML なので、中に <script> を書けます。ブラウザが画像として開くと、そのスクリプトが動きます。画像のアップロード欄が XSS の入口になります。

File::image(allowSvg: true) で許可できますが、許可するなら別の防御が要ります。配信するドメインをアプリケーションと分ける、Content-Security-Policy を付ける、といった対策です。この章では許可しません。

mimes と extensions は違うものを見ています

File::image()mimes:png は、ファイルの中身を読んで MIME タイプを推定します。名前は見ません。

extensions:jpg,png は逆です。利用者が付けた拡張子だけを見て、中身は見ません。

photo.txt という名前で PNG の中身を送ると、mimes:png は通り、extensions:png は落ちます。公式ドキュメントも、拡張子だけで判断しないよう明記しています。両方を組み合わせて初めて「名前と中身が一致した画像」になります。

保存する

app/Http/Controllers/ProductImageController.php
<?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 です。

bootstrap/app.php
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 を登録したのと同じ場所です。登録せずにルートへ書くと、ミドルウェアが見つからず例外になります。

routes/api.php
Route::post('/products/{product}/image', [ProductImageController::class, 'store'])
->middleware(['auth:sanctum', 'abilities:products:write']);
このミドルウェアだけでは何も守れません

第11章の登録とログインは、ability を明示してトークンを発行しています。

app/Http/Controllers/AuthController.php (第11章)
'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章で扱ったとおりです。

注文のルートには ability を付けません

abilities:order:create を注文のルートにも付けられます。この章では付けません。

目的は「顧客のトークンで商品画像を差し替えられないこと」で、それは発行側を絞った時点で達成されています。確認を掛けるのは、絞りたいルートだけで足ります。全ルートに付けると、ability を 1 つ増やすたびに全ルートを見直すことになります。

第11章に書いたとおり、ability と Policy は別の層です。ability は「このトークンで何ができるか」、Policy は「このユーザーがこのデータに対して何ができるか」を決めます。この章は前者だけを使います。

ability が足りないとき、Sanctum は MissingAbilityException を投げます。これは AuthorizationException を継承しているので、第9章の例外ハンドラが除外している型に入ります。403 で返ります。あのとき除外リストに AuthorizationException を書いていなければ、ここが 500 になっていました。

レスポンスに URL を出す

パスをそのまま返しても、クライアントは画像を表示できません。URL に変換します。

app/Http/Resources/ProductResource.php
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 の漏洩認可で止まる期限内は誰でも読める
取り消しすぐ効く期限が切れるまで効かない

temporaryUrllocals3 で使えます。local で動くのは、初期設定に 'serve' => true が入っているからです。古い記事が「local では使えない」と書いているのは、この設定が無かった頃の話です。

このガイドの選択

アプリケーションを通す形にします。第1章が「領収書 PDF のダウンロード」と約束したエンドポイントで、ファイルは数十 KB です。転送を節約する理由がありません。

署名付き URL へ切り替える条件は 2 つあります。ファイルが大きいときは、転送のあいだアプリケーションのプロセスが占有されます。S3 を使うときは、アプリケーションを経由すると S3 から取得してから返すので、往復が 2 倍になります。

app/Http/Controllers/ReceiptController.php
<?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",
);
}
}
routes/api.php
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 です。

app/Jobs/RecordPayment.php
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
app/Jobs/GenerateReceipt.php
<?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 になります

database/migrations/xxxx_add_receipt_path_to_orders_table.php
$table->string('receipt_path')->nullable();
app/Models/Order.php
#[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:publishconfig/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 個のファイルが残ります

app/Http/Controllers/ProductImageController.php
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章で作ったオブザーバに足します。

app/Observers/ProductObserver.php
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() を使います。

tests/Feature/ProductImageTest.php
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章で確認したのと同じ構造です。

領収書も同じ形で確認します。

tests/Feature/ReceiptTest.php
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 回だけ」を固定していました。領収書もまったく同じ性質を持ちます。

tests/Feature/RecordPaymentTest.php
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 は非公開、publicstorage: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_pathnull の場合を見ていません。 入金前の注文を叩くと、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 サーバーのユーザーが書けなければ、保存は失敗します。この章で throwtrue にしたので、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 つ目です。どちらも「アップロードは成功したように見える」ので、レスポンスを見ているだけでは区別できません。実際のディレクトリを見にいってください。