Building Web Apps with Laravel Module 7 – Validation, form requests and file uploads
File uploads, storage disks and images
Validate Laravel 13 uploads by their bytes, store them privately under generated names, hand them out with signed links, and resize on a queue.
What you will learn
- Validate an upload by its bytes, its content type and its size in pixels
- Store a file on a private disk under a generated name, never inside public
- Send a private file through a temporary link, and say what the signature covers
- Configure one S3-compatible disk for a local store and for production
- Inspect and resize an upload in a queued job rather than in the request
Before you start
What you’ll learn · Before you start
What you will learn
- Validate an upload by its bytes, its content type and its size in pixels
- Store a file on a private disk under a generated name, never inside public
- Send a private file through a temporary link, and say what the signature covers
- Configure one S3-compatible disk for a local store and for production
- Inspect and resize an upload in a queued job rather than in the request
Before you start
On this page
A parent uploads homework from a phone. The file says it is receipt.jpg of type image/jpeg, and its first bytes are <?php. A rule list that reads only the name accepts it; one that reads the bytes refuses it, and the recorded run shows both answers. A 1,600 by 1,200 photograph that is accepted takes 43 KB on disk and 1.6 KB once a queued job has cut it to a thumbnail.
This lesson takes one homework box from the form to a link that expires. You will see which methods repeat the sender’s claims and which read the file, how the file rules bound size, type and pixels, where an accepted file goes and under what name, what a signed link covers and what it does not, how one disk entry serves a local object store and a cloud bucket, and why the scanning and the resizing belong on a queue. Every run was recorded on Laravel 13.34.
An upload’s name and type are claims by the sender
An uploaded file arrives as an UploadedFile. Three of its methods repeat what the browser sent — getClientOriginalName(), getClientOriginalExtension() and getClientMimeType() — and two read the file itself: getMimeType(), which asks the content, and extension(), which follows from it. A caller who is not a browser fills in the first three with anything.
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- What the browser sends. The parent chooses a file. The browser sends its name and a content type, both of which the sender controls: a PHP script can arrive as receipt.jpg with the type image/jpeg.
- The rules read the bytes. File::types([pdf, jpg, png])->max(5mb) reads the file's own content to decide its type. The script recorded text/x-php, so it was refused although its name ended in .jpg.
- A refusal is a message. A refused upload answers like any other failed rule: 422 for a JSON client, a redirect back with the message for a form. Nothing is stored, so nothing has to be cleaned up.
- Stored under a name of its own. putFile stored the accepted file under forty generated characters and an extension read from its content type. The name the parent chose, जनवरी के नोट्स.pdf, is kept as data if you need it, never as a path.
- The work that waits. The request answers now and queues the rest. Two jobs were waiting in the recorded run, and nothing had been scanned or resized yet.
- The scanner decides. The job asks a scanner first. The file that began as a script was deleted and reported; the photograph passed and was cut to 256 by 256 as WebP, 43.1 KB down to 1.6 KB.
- Handed out for ten minutes. The batch teacher gets a temporary URL: the path, an expiry and a signature. The same signature on another path is refused, so one link opens one file.
The probe offers four files to one homework box: a photograph it draws itself, a PHP script named receipt.jpg with the type image/jpeg, a PDF over the size limit and a PDF under it:
from session import run
# Four files offered to one set of upload rules, with what each file claims and what its bytes say.
run(
"""
php upload_rules.php
""",
app=True,
overlay=("upload-probe",),
) Output
$ php upload_rules.php == a photograph claimed name photo.jpg, type image/jpeg bytes say type image/jpeg, 25 KB, 1200 by 800 dimensions on every file accepted dimensions on pictures only accepted == a PHP script named .jpg claimed name receipt.jpg, type image/jpeg bytes say type text/x-php, 71 bytes dimensions on every file The homework field must be a file of type: pdf, jpg, png.; The homework field has invalid image dimensions. dimensions on pictures only The homework field must be a file of type: pdf, jpg, png. == a PDF over the size limit claimed name fees.pdf, type application/pdf bytes say type application/pdf, 6,836 KB dimensions on every file The homework field must not be greater than 5000 kilobytes.; The homework field has invalid image dimensions. dimensions on pictures only The homework field must not be greater than 5000 kilobytes. == a PDF under it claimed name fees.pdf, type application/pdf bytes say type application/pdf, 2 KB dimensions on every file The homework field has invalid image dimensions. dimensions on pictures only accepted == the PHP script against extension-only rules accepted: the name ends in .jpg, so the rule is satisfied
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 upload_rules_session.py
For the script, claimed says name receipt.jpg, type image/jpeg and bytes say type text/x-php, 71 bytes. The two disagree, and only the second is evidence. The last block of the run is the one to remember: against a rule list of required, file and extensions:pdf,jpg,png, that same script was accepted, because the name ends in .jpg and that is all the rule reads.
Check it on your own upload route
Rename a small PHP file to .jpg and post it to your own form. A rule list that reads the content refuses it with a message; one that reads only the name accepts it. The test that fails before you add File::types and passes after is worth keeping.
The same holds for the name as a path. The disk probe below stores a file whose original name is जनवरी के नोट्स.pdf, and stores it as forty generated characters: a sender-chosen name in a path is how a file lands where you did not mean it to, or overwrites one that was already there. Keep the original name as a column beside the row, which is data, and never as a path.
Checkpoint
Check: What the sender claims
Rules for size, type and the shape of a picture
The file rules are a rule object. File::types(['pdf', 'jpg', 'png'])->max('5mb') reads the content to decide the type and bounds the bytes; File::image() is the same for pictures only; Rule::dimensions() bounds width, height and ratio. The recorded messages name the limit in kilobytes — must not be greater than 5000 kilobytes — because max('5mb') is 5,000 KB.
| What you want bounded | Rule | Why it matters |
|---|---|---|
| the bytes | File::types([...])->max('5mb') |
the request, the disk and the backup all pay for the size |
| the real type | File::types([...]), or mimes: |
the name proves nothing; this reads the content |
| the sender’s file name | extensions:pdf,jpg,png |
the name you keep beside the row and give back on download |
| the pixels | Rule::dimensions()->maxWidth(2000) |
memory to decode follows width times height |
The probe runs two rule lists over the same four files, and the difference between them is a trap worth seeing. With a dimensions rule applied to the field, the valid 2 KB PDF was refused with The homework field has invalid image dimensions. — a PDF has no size in pixels, so a pixel rule can never pass for it.
The fix is to add the pixel rules only for files whose own content type begins with image/. sometimes() does that, and in a form request it goes in withValidator(), which runs before validation:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\File;
use Illuminate\Validation\Validator;
// The homework box, as a form request: a document or a picture, at most 5 MB, with a limit in pixels that
// applies only to the files that have a size in pixels at all.
class StoreHomeworkRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'homework' => ['required', File::types(['pdf', 'jpg', 'png'])->max('5mb')],
];
}
/**
* Runs before validation, so a rule added here is applied. The condition reads the file's own bytes, not
* the name or the type the sender claimed.
*/
protected function withValidator(Validator $validator): void
{
$validator->sometimes(
'homework',
[Rule::dimensions()->maxWidth(2000)->maxHeight(2000)],
fn ($input) => str_starts_with((string) $input->homework?->getMimeType(), 'image/'),
);
}
} Key idea
Bound the bytes and the pixels separately. Megabytes protect the request and the disk; pixels protect the worker that will decode the picture, because the memory it needs follows width times height, not the size of the file.
The OWASP file upload advice is this combination rather than any single rule: an allowlist of extensions, a check of the content type, and a size limit. Each covers a different step, from accepting the bytes to handing them out again.
Checkpoint
Check: Rules for files and pictures
Where the file goes, and under what name
A filesystem disk: A named place to put files, with one set of methods for all of them: a local folder, a cloud bucket or any S3-compatible store. is a named place to put files, with one set of methods over all of them. A fresh Laravel 13 project has two local ones: local, whose root is storage/app/private, and public, whose root is storage/app/public and which becomes web-readable once php artisan storage:link has made public/storage point at it.
putFile stores an upload on a disk and returns the path it chose, which is hashName(): forty random characters, then an extension worked out from the file’s content type rather than copied from its name. The probe stores one file and then asks the three questions worth asking:
from session import run
# One upload stored on the private disk, then asked for three ways: unsigned, with its link, and with a changed path.
run(
"""
php disk.php
""",
app=True,
overlay=("disk-probe",),
) Output
$ php disk.php == what the route did with one upload client name जनवरी के नोट्स.pdf stored as homework/<40 random characters>.pdf link /storage/homework/<40 random characters>.pdf link query expires and signature, valid for 10 minutes on disk storage/app/private/homework/<40 random characters>.pdf public? nothing in public/ == the plain path, no signature status 403 == the signed link it gave out status 200 headers application/pdf, inline; filename=<40 random characters>.pdf == the same signature, another path status 403
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 disk_session.py
The first block shows the client name जनवरी के नोट्स.pdf, the stored path homework/<40 random characters>.pdf, and nothing in public/. The private disk is not under the document root, so no web server path reaches it at all; the only way in is the route the next section covers.
Writing uploads into public/
$file->move(public_path('homework')) or a public disk for private files makes every upload readable by anyone who knows or guesses the URL, and an unguessable name is not an access control: names travel in logs, caches, chat messages and browser history. Keep the public disk for files that are public by nature, such as a school logo.
Checkpoint
Check: Disks and generated names
Handing out a private file for ten minutes
The skeleton’s local disk has 'serve' => true, which registers a route for that disk’s files and requires a signature on every request. Storage::disk('local')->temporaryUrl($path, now()->addMinutes(10)) builds a signed URL: A URL with a signature in its query, a keyed hash of the whole URL. The server refuses a copy that was edited or has passed its expiry. More in “Routing, route parameters and URL generation” whose query carries an expiry and a signature over the path and that expiry together.
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The file is already there. One homework file is stored on the private disk, under a forty-character name the application generated. The folder is not inside public/, so no web server path reaches it.
- Asking for the plain path. The teacher tries the path with no query: /storage/homework/<the generated name>.pdf.
- Refused, 403. The disk has serve set to true, so the route exists, and it requires a valid signature. Without one this run answers 403 (404 in production) and the disk is never read.
- The link the application gave. temporaryUrl() returned the same path with two values in its query: an expiry and a signature over the path and that expiry. The recorded run asked for ten minutes.
- Checked, then read. The signature matches the path and the expiry has not passed, so the route reads the file from the disk and answers 200 with application/pdf and the generated name in the disposition.
- The teacher gets the file. A link, not a copy: the file stayed on the private disk, and the link stops working once its ten minutes are over.
- The same signature, another path. The last request keeps the signature and changes the path to another file. The answer is 403: the signature covers the path, so it cannot be moved to a file the teacher was not given.
The recorded run asks for the same stored file three ways, and the three statuses are the whole lesson: 403 for the plain path with no signature, 200 with application/pdf for the link the application gave out, and 403 for the same signature with the path changed to another file. The signature covers the path, so it cannot be moved. Those refusals are 403 because the probe runs outside production; when the application’s environment is production, the same route refuses with 404.
A temporary URL: A link to a stored file that carries an expiry and a signature over the path, so it opens one file for a while and cannot be edited to open another. needs no table, which is also its limit. It cannot be withdrawn one link at a time, and it proves the link rather than the person holding it: a teacher who forwards it has shared the file until it expires. That makes it right for a file a reader may keep for a while, and wrong for something confidential, which belongs behind a route that authorizes the caller on every request and streams the file itself with Storage::download().
Checkpoint
Check: Temporary links
The same code against an S3-compatible store
Once a school has two web servers, a file on one server’s disk is a file the other cannot serve. The usual answer is object storage: Storage that keeps files as objects in a bucket reached over HTTP, rather than on one server's disk, so every server of an application can read them., and the disk abstraction means your code does not change: putFile, temporaryUrl, download and delete are the same methods on an s3 disk, where a temporary URL becomes a presigned link the store itself checks. The s3 driver needs one package a fresh project lacks, which the documentation installs with composer require league/flysystem-aws-s3-v3 "^3.0" --with-all-dependencies. The project the other examples here run on does not have it either, so the two files below are shown rather than run.
One disk entry serves both a store running on your own machine during development and a cloud bucket in production:
<?php
// The disks of config/filesystems.php that matter for uploads, with the s3 disk pointed at any S3-compatible
// service. The same disk entry serves a locally run object store during development and a cloud bucket in
// production: only the environment file changes, and nothing in the application code does.
return [
'default' => env('FILESYSTEM_DISK', 'local'),
'disks' => [
// Private by default: storage/app/private, and files are handed out only through a signed route.
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
],
// Everything here is readable by anyone who knows the URL, through the public/storage symbolic link.
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
],
// An S3-compatible disk. AWS_ENDPOINT points at the service; a locally run store usually needs
// AWS_USE_PATH_STYLE_ENDPOINT=true, because it has no per-bucket host names.
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
],
],
]; # Development, against an S3-compatible store running on this machine. The keys are the store's own
# development credentials, kept out of the repository like every other secret.
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=local-development-key
AWS_SECRET_ACCESS_KEY=local-development-secret
AWS_DEFAULT_REGION=ap-south-1
AWS_BUCKET=pathshala-homework
AWS_ENDPOINT=http://127.0.0.1:9000
AWS_USE_PATH_STYLE_ENDPOINT=true
# Production, against the cloud bucket. The application code is the same; only these lines differ.
# FILESYSTEM_DISK=s3
# AWS_DEFAULT_REGION=ap-south-1
# AWS_BUCKET=pathshala-homework-production
# AWS_USE_PATH_STYLE_ENDPOINT=false
# AWS_ENDPOINT is left out: without it the client uses the region's own address. Set side by side, the two environment blocks differ only in these settings:
| Setting | The store on your machine | The cloud bucket |
|---|---|---|
AWS_ENDPOINT |
http://127.0.0.1:9000 |
not set |
AWS_USE_PATH_STYLE_ENDPOINT |
true |
false |
AWS_BUCKET |
pathshala-homework |
pathshala-homework-production |
| the keys | the store’s development keys | the production keys, kept out of the repository |
The line to notice is AWS_USE_PATH_STYLE_ENDPOINT. A locally run S3-compatible store has no per-bucket host names, so the bucket goes in the path and the flag is true; a cloud bucket uses host names and the flag is false. The code and the disk entry stay the same, which is what makes the move from development to production boring.
Keep the bucket private. A bucket with public read access makes every file readable by anyone with the URL, and it cannot be made private again for the copies already in somebody’s history. A private bucket with presigned links gives a reader the same experience and leaves you a way to stop handing out access.
Checkpoint
Check: An S3-compatible disk
Scanning and resizing belong on a queue
The documentation for Laravel’s image API says image work can be heavy in processor time and memory, and advises a queued job rather than the request that receives the upload. The OWASP cheat sheet asks for uploads to be scanned before they are used. Both belong in the same place: a job that runs after the request has answered, and before the application hands the file out.
The probe stores two uploads, queues a job for each, runs a worker until the queue is empty, and reports what the jobs left behind:
<?php
namespace App\Jobs;
use App\Contracts\UploadScanner;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Storage;
/**
* The work that must not happen inside the request: scan the file, and make a small copy of a picture.
* Until this job has run, nothing in the application hands the file out.
*
* The small copy is cut and encoded with PHP's own GD functions here, so that this page's numbers come from a
* run on the pinned project. With intervention/image installed, the same three lines are the Image facade's
* fromPath()->cover()->toWebp()->storeAs(), which is what a real project would use (see the lesson).
*/
class ProcessHomeworkUpload implements ShouldQueue
{
use Queueable;
public function __construct(public string $path) {}
public function handle(UploadScanner $scanner): void
{
$disk = Storage::disk('local');
$report = ['path' => $this->path, 'bytes before' => $disk->size($this->path)];
if ($reason = $scanner->reasonToReject($disk->path($this->path))) {
$disk->delete($this->path);
$this->report($report + ['result' => "deleted: {$reason}"]);
return;
}
if (! str_starts_with((string) $disk->mimeType($this->path), 'image/')) {
$this->report($report + ['result' => 'scanned, kept as it is']);
return;
}
$small = 'homework/small/'.pathinfo($this->path, PATHINFO_FILENAME).'.webp';
$box = $this->cover($disk->path($this->path), $disk->path($small), 256, 256);
$this->report($report + [
'size before' => implode(' by ', $box['before']),
'result' => 'scanned, then resized',
'small' => $small,
'bytes after' => $disk->size($small),
'size after' => implode(' by ', $box['after']),
]);
}
/**
* Cut the middle of the picture to the given shape and write it as WebP, as a cover transformation does.
*
* @return array{before: array{int, int}, after: array{int, int}}
*/
private function cover(string $from, string $to, int $width, int $height): array
{
$source = imagecreatefromstring((string) file_get_contents($from));
[$sourceWidth, $sourceHeight] = [imagesx($source), imagesy($source)];
$scale = max($width / $sourceWidth, $height / $sourceHeight);
$cutWidth = (int) round($width / $scale);
$cutHeight = (int) round($height / $scale);
$target = imagecreatetruecolor($width, $height);
imagecopyresampled($target, $source, 0, 0,
(int) round(($sourceWidth - $cutWidth) / 2), (int) round(($sourceHeight - $cutHeight) / 2),
$width, $height, $cutWidth, $cutHeight);
@mkdir(dirname($to), recursive: true);
imagewebp($target, $to, 80);
imagedestroy($source);
imagedestroy($target);
return ['before' => [$sourceWidth, $sourceHeight], 'after' => [$width, $height]];
}
/** The job's outcome, written where the probe can read it. A real application would update a row. */
private function report(array $lines): void
{
Storage::disk('local')->append('reports.json', json_encode($lines));
}
} from session import run
# Two uploads stored and queued, a worker run until the queue is empty, and what the jobs left behind.
run(
"""
# Terminal 1: the request stores the files and queues the work
php pipeline.php
# Terminal 2: the worker, which in production runs all the time
php artisan queue:work --stop-when-empty
php report.php
""",
app=True,
overlay=("pipeline",),
) Output
# Terminal 1: the request stores the files and queues the work $ php pipeline.php stored homework/notes.jpg (43 KB) stored homework/sums.jpg (34 bytes) queued 2 job(s) waiting small/ nothing yet # Terminal 2: the worker, which in production runs all the time $ php artisan queue:work --stop-when-empty … App\Jobs\ProcessHomeworkUpload ................. RUNNING … App\Jobs\ProcessHomeworkUpload .................... DONE … App\Jobs\ProcessHomeworkUpload ................. RUNNING … App\Jobs\ProcessHomeworkUpload .................... DONE … Worker STOPPED Queue empty $ php report.php path homework/notes.jpg bytes before 43.1 KB size before 1600 by 1200 result scanned, then resized small homework/small/notes.webp bytes after 1.6 KB size after 256 by 256 path homework/sums.jpg bytes before 34 bytes result deleted: the file begins as a script files now homework/notes.jpg, homework/small/notes.webp queued 0 job(s) waiting
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pipeline_session.py
At the moment the request answered, both files were stored, 2 job(s) waiting, and nothing yet in the small folder. After the worker ran, the photograph had been scanned and cut from 43.1 KB at 1,600 by 1,200 to 1.6 KB at 256 by 256, and the file that began as <?php had been deleted with the reason the file begins as a script. The listing at the end shows only the photograph and its small copy.
The scanner is an interface with one method, so a test can bind an answer instead of running a service. In production that implementation asks a scanner that keeps its own definitions, such as a ClamAV daemon or a scanning service; the job, the order of the steps and the tests do not change.
The Image facade needs one package
Laravel’s image API is powered by Intervention Image, which the documentation installs with composer require intervention/image:^4.0, plus the GD or Imagick extension. The project this page runs on does not have that package, so the recorded job cuts and encodes with PHP’s own GD functions and the numbers above are real. With the package installed, the same three steps read as the facade’s chain.
<?php
namespace App\Jobs;
use App\Contracts\UploadScanner;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Image;
use Illuminate\Support\Facades\Storage;
/**
* The same job written with Laravel's image API, which is what a project that has run
* `composer require intervention/image:^4.0` would use. The chain reads as the sentence it is: take this file,
* cover a 256 by 256 box with it, encode it as WebP at quality 80, and store it on the private disk.
*/
class ResizeHomeworkPicture implements ShouldQueue
{
use Queueable;
public function __construct(public string $path) {}
public function handle(UploadScanner $scanner): void
{
$disk = Storage::disk('local');
if ($reason = $scanner->reasonToReject($disk->path($this->path))) {
$disk->delete($this->path);
return;
}
$small = Image::fromPath($disk->path($this->path))
->cover(256, 256)
->quality(80)
->toWebp()
->storeAs('homework/small', pathinfo($this->path, PATHINFO_FILENAME).'.webp', 'local');
// $small is the stored path, and dimensions() reads the result back: [256, 256].
}
} Checkpoint
Check: Scanning and resizing on a queue
Practise
Build the homework box on your own Pathshala project.
- Accept a PDF or a picture of at most 5 MB with
File::types([...])->max('5mb'), and add the pixel limit withsometimes()inwithValidator(). Post a PDF and confirm it is not refused for its dimensions. - Rename a small PHP file to
.jpg, post it, and read the message. Then remove the content check, post it again, and see it accepted. - Store accepted files on the private disk with
putFile, keep the original name in a column, and give the batch teacher a ten-minutetemporaryUrl. Ask for the same path without the query and confirm the refusal: 403 outside production, 404 in it. - Queue a job that asks a scanner interface and then writes a small copy. Bind a scanner in a test that refuses one fixture and accepts another, and assert that the refused file is gone.
- Point
FILESYSTEM_DISKat an S3-compatible store you run locally, with the path-style flag on, and check that nothing in your code had to change.
Then work through the self-check.
Practice that adapts to you
Practise this lesson
13 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: how do you decide whether an upload is the type it claims to be?
By reading the file, not the request. The browser sends a name and a content type, and both are chosen by whoever sends the request: the recorded probe shows a PHP script arriving as receipt.jpg with the type image/jpeg. getClientOriginalName() and getClientMimeType() repeat that claim, while getMimeType() reads the file’s own bytes and returned text/x-php for the same file. In rules that means File::types([...]) or mimes:, which read the content, rather than extensions:, which reads the name — and the recorded run shows an extension-only rule list accepting the script. Keep the extension rule as well, because it bounds the name you keep beside the row and give back on download, and add a size limit and, for pictures, a limit in pixels. Then store under a generated name, so the sender’s name never becomes a path.
These questions take the homework box further:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A security audit finds that a homework box accepts a PHP file renamed to .jpg, and that uploads are written into public/uploads under the name the parent chose. Say what each of those allows, and fix both.
-
Mid to senior
Report cards are handed out as ten-minute signed links. A parent forwards one to a group of forty, and the school asks you to make sure that cannot happen again. Explain the trade-off and choose.
-
Mid to senior
Thumbnails are made in a queued job. The worker dies on some uploads although every file is under the 5 MB limit, and those uploads never get a thumbnail. Diagnose it and say what you would change.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How do you tell whether an upload is really the type it claims?
Read its bytes, not the request: the name and content type are the sender's claims. getMimeType() returned text/x-php for a PHP script sent as receipt.jpg, so use rules that read content, File::types or mimes:, not extensions: alone.
-
Why bound an image upload's pixels as well as its bytes?
Megabytes protect the request and the disk, but decoding a picture needs memory in proportion to width times height, not to the file's size. Add the dimensions rule with sometimes() so documents without pixels are not refused.
-
Where does the local disk store an upload, and under what name?
Under storage/app/private, and putFile stores it under forty generated characters, so nothing lands in public/ and no sender-chosen name becomes a path.
-
What did the recorded run show for a private file's temporary URL?
403 for the plain path, 200 for the real link and 403 once the path was changed: the link carries an expiry and a signature over the path.
-
What changes between a locally run S3-compatible store and a cloud bucket?
Only the endpoint and the path-style flag of the one s3 disk entry. The bucket stays private and files go out through presigned links.
-
Why do scanning and resizing run in a queued job rather than in the upload request?
The request answers at once, and the worker does the slow part afterwards: in the recorded run it deleted the file that began as a script and cut the photograph from 43.1 KB to 1.6 KB.
Key takeaways
- An upload’s name, extension and content type are claims by the sender;
getMimeType()reads the bytes, and the recorded probe shows an extension-only rule list accepting a PHP script namedreceipt.jpg. - Bound the bytes with
max('5mb'), the type withFile::types([...]), the sender’s file name withextensions:, and a picture’s pixels with a dimensions rule added bysometimes()so documents are not refused for having no pixels. - The local disk writes to
storage/app/privateandputFilestores under forty generated characters, so nothing lands inpublic/and no sender-chosen name becomes a path. - A temporary URL carries an expiry and a signature over the path, which the recorded run shows as 403, 200 and 403 for the plain path, the real link and a changed path.
- One
s3disk entry serves a locally run store and a cloud bucket, with the path-style flag and the endpoint as the only difference, and the bucket stays private behind presigned links. - Scanning and resizing run in a queued job: the recorded worker deleted the file that began as a script and cut the photograph from 43.1 KB to 1.6 KB, after the request had already answered.
Terms in this lesson
- filesystem disk
- A named place to put files, with one set of methods for all of them: a local folder, a cloud bucket or any S3-compatible store.
- object storage
- Storage that keeps files as objects in a bucket reached over HTTP, rather than on one server's disk, so every server of an application can read them.
- temporary URL
- A link to a stored file that carries an expiry and a signature over the path, so it opens one file for a while and cannot be edited to open another.
References
- File Storage (Laravel (laravel.com))
- Laravel framework v13.34.0 source, ServeFile.php (Laravel (GitHub))
- Validation: validating files (Laravel (laravel.com))
- Image Manipulation (Laravel (laravel.com))
- File Upload Cheat Sheet (OWASP)
- Queues (Laravel (laravel.com))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress