File storage — product images and receipt PDFs
The last two chapters were about deferring heavy processing and not repeating the same query. All the data involved was database rows.
This chapter faces something different: product images and receipt PDFs — files that do not go into the database.
Files split along two axes: where they come from and who may see them. A product image arrives from outside and anyone may see it; a receipt is created by us and only its owner may see it. Building the two side by side shows where the design diverges.
At the end we handle a problem that appears only with files. DB::transaction() does not roll back files. It is the one place where the tool acquired in chapter 14 does not work.
The image upload tests require the GD extension, because UploadedFile::fake()->image() generates a real image file. Check with php -m | grep gd.
dompdf, which creates the PDF, also lists GD in its requirements. It should be unnecessary if you put no images in the receipt HTML, but that is unverified. Having it is the safe option.
The disk abstraction
The configuration is in config/filesystems.php. Three disks are defined initially.
'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',
// ... credentials and bucket name ...
],
],
local and public use the same driver. The only differences are where files go and whether they can be read from outside.
| Disk | Location | Readable by URL |
|---|---|---|
local | storage/app/private | No |
public | storage/app/public | Yes, once you run storage:link |
s3 | An S3 bucket | Decided by the bucket's configuration |
That table is this chapter's design. What you decide when placing a file is not the location but who gets to see it.
Name the disk when you use it.
Storage::disk('public')->put('a.txt', $contents);
Omit it and FILESYSTEM_DISK from .env (default local) is used. Do not omit it. The day the default changes, private files move to the public disk. And nothing errors.
Run storage:link
Making files on the public disk readable over HTTP requires a symbolic link.
php artisan storage:link
That creates a link from public/storage to storage/app/public. The link definition lives in the configuration file.
'links' => [
public_path('storage') => storage_path('app/public'),
],
Put this command in your deployment procedure. The link is not in the repository, so it does not exist on a new server. Chapter 21 includes it in the procedure.
Why not put files straight in public/
Putting uploaded files in public/uploads/ works. There are three reasons to go through storage/ anyway.
They disappear on deployment. With a scheme that unpacks each release into a new directory and switches over, the contents of public/ are replaced too. Files placed under the previous release are not in the new one.
Code and artifacts get mixed. Looking at the directory does not tell you what came from the repository and what accumulated at runtime. That is awkward when deciding what to back up.
You cannot move to S3. Go through Storage and changing the configuration moves the location. Code that writes directly into public/ has to be rewritten.
A failed write does not throw by default
Because throw is false, the only sign that a write failed is the return value.
if (! Storage::disk('public')->put($path, $contents)) {
// the write failed
}
Neither a full disk nor missing permissions throws. Code that ignores the return value proceeds believing it saved, and a path pointing at a nonexistent file is all that remains in the database.
This guide sets throw to true.
'local' => [
// ...
'throw' => true, // ← changed
],
'public' => [
// ...
'throw' => true, // ← changed
],
A failure throws League\Flysystem\UnableToWriteFile, and the exception handler built in chapter 9 returns a 500 with a tracking ID. Making the check impossible to forget is more reliable than checking a return value on every write. The code in this chapter does not inspect the return values of put() or store() on the strength of this setting.
Receiving an upload
Let us receive a product image: POST /api/v1/products/{product}/image from the chapter 1 endpoint list.
Add the column
We need a column to store the path.
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->string('image_path')->nullable();
});
}
Fix #[Fillable] as part of the same job.
#[Fillable(['name', 'description', 'price', 'image_path'])]
Chapter 14 said "creating the migration and forgetting the attribute is an easy shape to fall into," and chapter 17 handled the same pair. A column and its attribute are one unit. Forget it and update() silently discards the value, so the upload returns 200 while no image appears.
Validate it
Format validation goes in a Form Request — the home settled in chapter 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() allows jpg, jpeg, png, bmp, gif, and webp. Sizes can be written as strings, with kb, mb, gb, and tb available. A bare number means kilobytes, so writing the unit avoids a misreading.
dimensions adds an upper bound on width and height. Watch only the file size and a 1 MB image at 20,000 × 20,000 pixels gets through. Try to process it and you run out of memory.
File::image() rejects SVG. That default is deliberate.
SVG is XML, so it can contain a <script>. When a browser opens it as an image, that script runs. An image upload field becomes an XSS entry point.
You can allow it with File::image(allowSvg: true), but doing so needs other defenses: serving from a domain separate from the application, adding a Content-Security-Policy, and so on. This chapter does not allow it.
File::image() and mimes:png read the file's contents to infer the MIME type. They do not look at the name.
extensions:jpg,png is the opposite. It looks only at the extension the user supplied and never at the contents.
Send PNG contents under the name photo.txt and mimes:png passes while extensions:png fails. The official documentation states clearly that you should not decide by extension alone. Only combining both gives you "an image whose name and contents agree."
Store it
<?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() takes a directory as its first argument and a disk as its second. No filename is specified. Laravel generates a unique name and decides the extension from the MIME type. What comes back is a path relative to the disk's root, which goes into the column.
getClientOriginalName() gets you the original filename, but the official documentation marks it unsafe.
Send the same name and you overwrite the previous file. Someone can mix ../ into the name. Names containing non-ASCII characters or spaces break as URLs.
When you want to decide the name yourself, use hashName() (a random unique name) and extension() (an extension inferred from the contents).
Who may replace it
Here a question arises. Who should be able to call this endpoint?
The Policy built in chapter 12 does not apply. A Policy decides "this user, against this model," which needs an ownership relationship to work from. An order has someone who placed it, but a product has no owner.
This guide builds no admin features, so there is no administrator role either. What we can use is the ability covered in chapter 11.
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, // ← added
'ability' => CheckForAnyAbility::class, // ← added
]);
})
The alias registration is required. It is the same place webhook.payment was registered in chapter 13. Write it on a route without registering and the middleware is not found and it throws.
Route::post('/products/{product}/image', [ProductImageController::class, 'store'])
->middleware(['auth:sanctum', 'abilities:products:write']);
Sign-up and login in chapter 11 issue tokens with abilities stated explicitly.
'token' => $user->createToken('api', ['order:create', 'order:read'])->plainTextToken,
Omit that second argument and it becomes ['*'] — every ability. With it omitted, abilities:products:write lets every registered user through. You think you are protected and you are not.
Narrowing those two places in chapter 11 was for this chapter. What actually protects this endpoint is that customer tokens do not carry products:write. The middleware merely checks the result of that narrowing.
Check now that createToken()'s second argument is not omitted in your own code.
Issue the token for replacing product images separately. With no admin screen, it is a manual job.
php artisan tinker
User::find(1)->createToken('product-admin', ['products:write'])->plainTextToken;
The string displayed can only be seen at that moment, as chapter 11 covered.
You could add abilities:order:create to the order routes too. This chapter does not.
The goal is "a customer's token cannot replace product images," and that is already achieved by narrowing the issuing side. Applying the check only on the routes you want narrowed is enough. Put it on every route and adding one ability means reviewing every route.
As chapter 11 said, abilities and Policies are different layers. An ability decides what this token can do; a Policy decides what this user can do to this data. This chapter uses only the former.
When an ability is missing, Sanctum throws MissingAbilityException. It extends AuthorizationException, so it falls into the types excluded by chapter 9's exception handler and comes back as 403. Had we not written AuthorizationException into that exclusion list, this would have been a 500.
Return the URL in the response
Returning the path as is leaves the client unable to display the image. Convert it to a 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() returns /storage/products/xxxx.jpg on the public disk and a full URL on S3. The calling code does not change.
Return null for a product with no image. We do not remove the key the way whenLoaded does. With the key gone, a client cannot tell "there is no image" from "the implementation changed." Whether a relationship is loaded (chapter 7) and whether a value exists are different questions.
This image_url rides on the product listing cached in chapter 18 too. image_path is a column, so it is readable from the cached model. Replacing an image fires ProductObserver::saved and invalidates the cache. Chapter 18 pushed invalidation into the observer precisely because update paths get added later like this.
Laravel has an API for image manipulation (it needs intervention/image added). The official documentation warns that image processing consumes CPU and memory and should be done in a queued job. Run a resize inside the request and you have brought back, in another form, the heavy processing chapter 16 moved out. This chapter does no processing.
Handing over a private file
Only the owner sees a receipt. Both the location and the delivery differ from a product image.
Where to put it
Put it on the local disk (storage/app/private). It must not go in public.
Anyone who knows the URL can read it. With sequential order IDs, someone can walk receipts/1.pdf onward. Other people's receipts become readable without passing through a single line of authorization code.
Choosing between 404 and 403 in chapter 12, and handling ID enumeration in chapter 13, were both about avoiding this shape. Get the location wrong and all of it is void.
There are two ways to hand it over
Through the application returns the file's contents from a controller.
return Storage::disk('local')->download($order->receipt_path, "receipt-{$order->id}.pdf");
The request reaches the application, so the authorization code always runs.
Returning a signed URL returns only a time-limited URL.
$url = Storage::disk('local')->temporaryUrl(
$order->receipt_path, now()->plus(minutes: 5)
);
The download itself does not pass through the application. On S3, S3 returns it directly.
| Through the application | Signed URL | |
|---|---|---|
| Authorization | Decided per request | Once, at issue time |
| Transfer | Goes via the application | Direct, on S3 |
| A leaked URL | Stopped by authorization | Readable by anyone until it expires |
| Revocation | Takes effect immediately | No effect until it expires |
temporaryUrl works on local and s3. It works on local because the initial configuration includes 'serve' => true. Older articles saying "it does not work on local" date from before that setting existed.
What this guide chooses
We go through the application. It is the endpoint chapter 1 promised as "download the receipt PDF," and the files are tens of kilobytes. There is no reason to economize on transfer.
There are two conditions for switching to a signed URL. When files are large, the application process is tied up for the duration of the transfer. When using S3, going via the application means fetching from S3 and then returning it, doubling the round trips.
<?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 () {
// ... existing routes ...
Route::get('/orders/{order}/receipt', [ReceiptController::class, 'show']); // ← added
});
It is narrowed the same way as chapter 12. Searching from $request->user()->orders() means someone else's order is treated as nonexistent and gets a 404. Using Gate::authorize('view', $order) would give a 403, contradicting chapter 12's decision to hide an order's existence.
A receipt that has not been generated is also a 404. It falls under "nothing matches the ID" in chapter 4's status code table. Being called before payment is not an anomaly; it is one of the normal states.
Generating artifacts in a job
When to create it
The receipt is created once payment is confirmed. What knows that moment is chapter 17's 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); // ← added
}
It goes below the early return. The structure built in chapter 17 works directly. However many times the same notification arrives, the receipt is generated once.
You could also dispatch a PaymentRecorded event and receive it in a listener — the choice mentioned in chapter 17. While there is one recipient, queueing directly from the job is easier to follow. The third one is what turns it into a decision to switch to an event.
What to create it with
PDF generation is not a Laravel feature. Add a library.
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>Receipt</h1>
<p>Order number: {$this->order->id}</p>
<p>Amount: {$amount} yen</p>
</body>
</html>
HTML;
}
}
output() returns the PDF's bytes. Writing to a file is Storage's job, so the library never touches the filesystem. The decision about where things go stays in one place.
There is also loadView() for passing a Blade template. We do not use it here. Polishing the appearance is not this chapter's subject, so the HTML is a minimal heredoc.
spatie/laravel-pdf is another option. It renders with a headless browser, giving higher CSS fidelity at the cost of needing a Node.js environment.
Add the column too. Forget it and update() silently discards the path, so the download is a permanent 404.
$table->string('receipt_path')->nullable();
#[Fillable([
'user_id', 'customer_email', 'status', 'total_amount',
'idempotency_key', 'confirmation_sent_at', 'shipment_status', 'receipt_path',
])]
You need a font for non-Latin text
There is a reason the code above writes an @font-face. Without it, the characters do not appear.
The fonts a PDF carries internally (Helvetica, Times-Roman, Courier, and so on) only cover Windows ANSI characters. DejaVu, bundled with dompdf, covers Latin, Greek, Cyrillic, Arabic, and more, but no CJK ideographs or kana.
Place the font.
mkdir -p storage/fonts
cp ~/Downloads/NotoSansJP-Regular.ttf storage/fonts/
dompdf loads a font referenced by @font-face, caches it in storage/fonts, and embeds it in the PDF. That location is set by the fontDir setting. The default is storage/fonts, so unless you change it you need no configuration file. To change it, run php artisan vendor:publish to produce config/dompdf.php.
Embedding puts the font's data into what you distribute. A font that does not permit embedding and redistribution cannot be used.
Noto Sans CJK is under the SIL Open Font License 1.1, which permits embedding. Copying and distributing a font bundled with an operating system, on the other hand, is usually not permitted.
This problem is not found by tests. Without the font, PDF generation still succeeds and a file is produced. Only the characters are missing. Open one by eye at least once.
Keep the path stable
The path is receipts/{order ID}.pdf — one location per order.
Run the job twice and it writes the same contents to the same place. This is what chapter 17 meant by "an operation that writes the same value gives the same result however many times you run it." Use a random value in the filename and you get one file per execution.
Chapter 18 made the opposite call. The cache version uses a UUID, guaranteed to differ every time. The goals are opposite. There it was "communicate that something changed"; here it is "keep pointing at the same place."
Product images go the other way again: we left them with random names. Fixing them at products/{product ID}.jpg would remove the work of deleting old files, but the URL would stop changing. A CDN and a browser have no way to know the contents behind the same URL changed, so a replacement keeps serving the old image. Sweeping up orphaned files is easier to handle than an image that will not go away.
There is one axis for the decision. Name things that go out as URLs so the name changes; fix the name for things that do not.
Chapter 15 mentioned attachFromStorage. With a Mailable, you can attach a file from storage.
But what you can attach it to is the payment confirmation notification. The order confirmation email built in chapter 15 goes out the instant an order is placed. At that moment receipt_path is still null, so the order confirmation email cannot carry the receipt.
Files and records drift apart
Storing and serving are done. Finally, a problem that appears only with files.
A transaction does not roll back files
DB::transaction(function () use ($request, $product) {
$path = $request->file('image')->store('products', 'public');
$product->update(['image_path' => $path]);
// if an exception flies here
});
The row update rolls back; the file remains. DB::transaction() manages only the database connection. The tool that closed double-allocated stock in chapter 14 does not work here.
That is not a bug. It is something you accept and design around.
Which way to lean
Writing the file and writing the row always happens in some order. What remains after a mid-way failure depends on that order.
Write the file first and, if the row is not updated, you are left with a file nobody references. It takes a little disk space, but nothing happens to users. You can delete it later.
Update the row first and, if the file write fails, a path pointing at a nonexistent file remains. A user opening the listing sees a broken image.
Write the file first. An orphaned file is a quiet problem; a broken link is a visible one. Lean toward the quiet one.
The mechanism for sweeping up orphaned files is built with the scheduler in the next chapter.
Delete the old one after a replacement
store() creates a different filename every time. Replace an image ten times and ten files remain.
use Illuminate\Support\Facades\Storage; // ← added
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);
}
The delete comes after the row update. Delete first and, if the update fails, you get "the row points at the old path and that file is gone." It is the same judgment as leaning toward the quiet failure above.
Follow deletions
When a product is deleted, delete its image too. Add it to the observer built in chapter 18.
use Illuminate\Support\Facades\Storage; // ← added
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);
}
}
Do not remove the cache version update. The line added in chapter 18 is still needed exactly as it is.
As chapter 18 covered, a bulk delete such as Product::where(...)->delete() does not dispatch model events. The observer is never called and the files remain.
An observer is not a comprehensive countermeasure. Assume misses and keep a separate mechanism that periodically sweeps up orphaned files. We build it in the next chapter.
Tests
We use the Storage::fake() promised in chapter 10.
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('a product image can be uploaded', 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('a token without the ability cannot replace it', function () {
Storage::fake('public');
$product = Product::factory()->create();
Sanctum::actingAs(User::factory()->create()); // no abilities passed
$this->postJson("/api/v1/products/{$product->id}/image", [
'image' => UploadedFile::fake()->image('sample.jpg'),
])->assertStatus(403);
Storage::disk('public')->assertEmpty();
});
Storage::fake('public') swaps that disk for a temporary test directory. The real storage/app/public stays clean, and the contents disappear when the test finishes.
Sanctum::actingAs()'s second argument is the abilities. Omit it and you get an empty array, which does not pass abilities:products:write. That is what makes the second test work.
UploadedFile::fake()->image() really generates an image at the given size. That generation needs the GD extension.
The second test is the heart of this chapter. With only the first, removing the middleware leaves it green. A test that "what should get through gets through" passes even with authorization broken. It is the same structure confirmed in chapter 12.
Confirm receipts the same way.
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('you can download your own receipt', 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("another user's receipt returns 404", function () {
Storage::fake('local');
$order = Order::factory()->create(['user_id' => User::factory()->create()->id]);
GenerateReceipt::dispatchSync($order);
Sanctum::actingAs(User::factory()->create()); // a different user
$this->getJson("/api/v1/orders/{$order->id}/receipt")->assertStatus(404);
});
test('a receipt that has not been generated returns 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() runs the job on the spot. It skips the queue, so you can check the state right after generation. dompdf really runs here, so a broken PDF generation makes it fail.
Add one line to chapter 17's test too. That one pinned down "the same notification twice still queues one shipping instruction." The receipt has exactly the same property.
use App\Jobs\GenerateReceipt; // ← added
// ... inside the test ...
$this->assertDatabaseCount('payments', 1);
Queue::assertPushed(NotifyWarehouse::class, 1);
Queue::assertPushed(GenerateReceipt::class, 1); // ← added
Without that line, deleting GenerateReceipt::dispatch() from RecordPayment leaves every test passing. Receipts simply stop being created and nothing else happens. Give every piece of wiring you add a test that fails when it is removed.
Two things still cannot be detected.
The font configuration. A PDF with no font placed still generates successfully and produces a file. assertDownload passes too. Whether characters are in it is invisible from a test.
The throw setting. That is a value in config/filesystems.php, so reverting it to false leaves every test in this chapter green. Producing a failing write in a test is itself hard.
You could write a one-line assert checking config('filesystems.disks.public.throw'). But that pins down a configuration value rather than behavior, and it needs the same judgment as chapter 17's "not worth the trouble" about the retry count. It is less that tests cannot guard it and more that the cost of guarding moves elsewhere. Either way, it is an item to review alongside chapter 9's error design.
Summary
- Choosing a disk is choosing who gets to see it.
localis private,publicbecomes public withstorage:link - Do not omit the disk name. A changed default moves private files to the public side
- Do not write directly into
public/. It disappears on deployment and cannot move to S3 throwdefaults tofalse, so a failed write is only visible in the return value. Set it totrueand lean on exceptions- A column and the model's
#[Fillable]are one unit. Forget it and the path is silently discarded File::image()rejects SVG, because XML can carry a scriptmimeslooks at contents andextensionsat the name. They look at different things- Do not use the filename the sender supplied. Use
hashName()andextension() - A Policy cannot be used for a resource with no owner. Narrow it with a Sanctum ability
- A token with no abilities specified carries
['*']. Without narrowing the issuing side, checking abilities is meaningless - There are two ways to hand over a private file. A signed URL checks authorization only once, at issue time
- Change the name for files that go out as URLs; fix the name for files that do not. The first is for the CDN, the second so a second run does not multiply files
- Supply the font yourself for a non-Latin PDF. Without it, generation succeeds and only the characters vanish
DB::transaction()does not roll back files. Lean toward the orphaned file- Observers do not fire on bulk deletes. Keep a separate periodic sweep
What to read next
The next chapter, The task scheduler — collapsing scheduled work into a single cron line, covers work that runs periodically. The orphaned files this chapter twice promised to "sweep up next chapter" are the first subject. Auto-cancelling unpaid orders is built there too. You register a single line with cron and manage the rest on the Laravel side.
Exercises
The following receipt endpoint has a serious problem. Point it out
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');
Sample answer
It never checks whose order it is.
Route model binding just fetches the model from the ID in the URL. It does not look at whose order that is. Anyone logged in can walk /api/v1/orders/1/receipt onward and collect everyone's receipt URLs.
As chapter 12 settled, use the form $request->user()->orders()->findOrFail($orderId). Searching within your own orders makes someone else's a 404.
There are two more problems.
The expiry is 24 hours. The issued URL is readable without passing through authorization for those 24 hours. Paste it into a chat and everyone there can read it. A receipt carries a name and purchase details, so that is a leak of personal information. "Until the user finishes downloading" is enough — a few minutes.
It never checks for a null receipt_path. Call it on an order before payment and it tries to build a URL from null. If it succeeds, you return a URL pointing at nothing; if it fails, it lands in chapter 9's exception handler as a 500. Neither communicates "it has not been issued yet." The implementation in the body returns 404 with abort_if in order to treat that as one of the normal responses.
You get a report that "the image upload tests are green but images do not show in production." What do you check first?
Sample answer
The tests are green, so the cause is outside what the tests cover. Storage::fake() never touches the real disk, so anything to do with location and serving is outside that boundary.
First, storage:link. The link is not in the repository. Deploy to a new server without running the command and public/storage does not exist. image_url keeps returning /storage/..., so the API response is correct and only the image is a 404. Check whether it was in the deployment procedure.
Second, write permissions. If the web server's user cannot write to storage/app/public, the save fails. Because this chapter set throw to true, a 500 with a tracking ID comes back and is logged. Check the logs first. As chapter 17 covered, running the worker as root and ending up with mismatched ownership produces the same symptom.
Leaving throw at its default of false makes this cause the hardest of all to find. put() merely returns false, so the upload returns 200, image_path gets a value, and only the file is missing. Changing that one setting changes how long the investigation takes.
Third, FILESYSTEM_DISK. If 'public' was omitted anywhere in Storage::disk('public'), the write goes to the default disk. The default is local, so the file lands in storage/app/private — a place that can never be read from a URL.
The order of checks can be settled from the symptom. If the file is in storage/app/public, it is the first; if not, it is the second or third. Both look like "the upload succeeded," so looking at the response alone cannot tell them apart. Go and look at the actual directory.