Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

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.

Interactive · stage by stage One upload, from the form to a link Step through one homework upload and see what each stage decides.
One upload, from the form to a link that expires, step 7 of 7. Showing The form, The upload rules (reads the content), The private disk (nothing in public/), A queued job (one kept, one gone), A signed link. In focus: A signed link.422, a messagedeleted if unsafeThe forma name and a typeThe upload rulesreads the contentThe private disknothing in public/A queued jobone kept, one goneA signed linkten minutes

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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:

Four files, two rule lists, and what the bytes say Python · upload_rules_session.py
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

  1. Question 1 of 2 The probe offered a PHP script named receipt.jpg with the declared type image/jpeg. What did getMimeType() return for it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: text/x-php, read from the file's own bytes

    Two methods, two sources. getClientOriginalName, getClientOriginalExtension and getClientMimeType repeat what the sender sent; getMimeType and extension come from the bytes. Only the second pair is evidence.

  2. Question 2 of 2 A rule list has only required, file and extensions:pdf,jpg,png. What happened when the probe offered it the PHP script named receipt.jpg?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It was accepted, because the name ends in .jpg

    extensions is still useful: it stops a file whose name would be stored or served with a type you do not want. It is a check on the name, so it belongs next to a check on the content, never instead of one.

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:

app/Http/Requests/StoreHomeworkRequest.php PHP · upload-probe/app/Http/Requests/StoreHomeworkRequest.php
<?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

  1. One question The homework box accepts PDFs and pictures, and the rule list applied a dimensions rule to the field. What did the recorded run report for a valid 2 KB PDF?

    Choose one answer.

    Show the answer: One question

    Answer: The homework field has invalid image dimensions.

    Rules that read a picture's pixels only make sense for pictures. Add them with sometimes() in withValidator(), under a condition that reads the file's own type, which is what the second rule list does.

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:

One upload stored privately, then asked for three ways Python · disk_session.py
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

  1. One question The route stored a file whose original name was जनवरी के नोट्स.pdf. Which statements match the recorded run?

    Choose every answer that is right.

    Show the answer: One question

    Answer:

    • It was stored as a forty-character name with the .pdf extension
    • Nothing was written inside public/

    Generating the stored name separates two jobs: the path, which your application decides, and the label a reader sees, which the parent chose. The download route sets the name a browser saves it under.

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.

Interactive · message by message Three ways of asking for one private file Step through the three requests and read the status each one got.
Three ways of asking for a private file, step 7 of 7. Messages so far: A teacher to The route: GET, no query; The route to A teacher: 403 Forbidden; A teacher to The route: GET, signed; The route to The disk: read the file; The route to A teacher: 200, the PDF; A teacher to The route: GET, path edited.A teachera browserThe routeit checksThe diskapp/privateGET, no query403 ForbiddenGET, signedread the file200, the PDFGET, path edited

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. Asking for the plain path. The teacher tries the path with no query: /storage/homework/<the generated name>.pdf.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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

  1. One question The probe asked for the same stored file three ways: the plain path, the signed link, and the signature with a changed path. What were the three statuses?

    Choose one answer.

    Show the answer: One question

    Answer: 403, 200, 403

    serve set to true on a local disk registers a route that checks the signature before it reads a file. The signature is over the path and the expiry together, which is why neither can be edited in the link.

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:

config/filesystems.php: the three disks that matter PHP · s3-config/filesystems.php
<?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,
        ],

    ],

];
.env: the same disk, two environments EXAMPLE · s3-config/env.example
# 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

  1. One question The homework feature is moved from the private local disk to an S3-compatible store. Which application code has to change?

    Choose one answer.

    Show the answer: One question

    Answer: None of it: the disk name and the environment file change

    That is the point of a disk: one set of methods over a local folder, an S3 bucket or any S3-compatible service. Keep the disk name in configuration rather than in the code, and the move is an environment change.

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:

app/Jobs/ProcessHomeworkUpload.php PHP · pipeline/app/Jobs/ProcessHomeworkUpload.php
<?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));
    }
}
Two uploads queued, a worker run, and what it left behind Python · pipeline_session.py
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.

The same job with the Image facade PHP · pipeline/app/Jobs/ResizeHomeworkPicture.php
<?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

  1. Question 1 of 2 In the recorded pipeline, what had happened to the two files at the moment the request answered?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Both were stored, two jobs were waiting, and nothing had been scanned or resized

    The request does the part that must be immediate: validate, store, answer. Scanning and image work are measured in seconds and megabytes of memory, which is why the documentation itself advises a queued job.

  2. Question 2 of 2 The job asked a scanner before anything else. What did the recorded run do with each file?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The script was deleted with a reason; the photograph was resized to 256 by 256 WebP

    Scan, then transform, then make available. The order matters: a file that is never scanned should never be handed out, and a file that fails the scan should leave nothing behind.

Practise

Build the homework box on your own Pathshala project.

  1. Accept a PDF or a picture of at most 5 MB with File::types([...])->max('5mb'), and add the pixel limit with sometimes() in withValidator(). Post a PDF and confirm it is not refused for its dimensions.
  2. 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.
  3. Store accepted files on the private disk with putFile, keep the original name in a column, and give the batch teacher a ten-minute temporaryUrl. Ask for the same path without the query and confirm the refusal: 403 outside production, 404 in it.
  4. 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.
  5. Point FILESYSTEM_DISK at 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.

  1. Question 1 of 13 Why is getClientOriginalName() unsafe to use as part of a storage path?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: The sender chooses it, so it can hold path segments or the name of an existing file

    Store under a generated name and keep the original as a column. The download route can then set the name the browser saves it under, so a reader still sees their own file name.

  2. Question 2 of 13 A teammate asks why the rule list has both a content check and an extension check, when the content check is the stronger one.

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: The content decides whether to accept it; the extension decides what it is called once stored

    The OWASP file upload advice is both: an allowlist of extensions and a check of the content type, with a size limit. Each covers a different step, from accepting the bytes to handing them out again.

  3. Question 3 of 13 File::types(['pdf', 'jpg', 'png'])->max('5mb') refused a PDF. The message named a limit in kilobytes. How many kilobytes did it name?

    Type a number.

    Show the answer: Question 3 of 13

    Answer: 5000 kilobytes

    The recorded message is "must not be greater than 5000 kilobytes": max('5mb') is 5,000 KB, the decimal megabyte. Write the limit in the unit the message will show, so a reader and your storage agree.

  4. Question 4 of 13 Where does a dimensions rule belong when the same field accepts documents and pictures?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: In withValidator(), added with sometimes() under a condition that reads the file's type

    sometimes() takes a field, extra rules and a condition over the input. It is the documented way to make part of a rule list conditional, and it reads the same whether the condition is a file's type or another field.

  5. Question 5 of 13 A profile picture must be a square image of at most 1,024 pixels a side and at most 2 MB. Which rule list says that, with the fewest surprises?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: File::image()->max('2mb') with a dimensions rule for the ratio and the maximum side

    Bound both the bytes and the pixels. Megabytes protect the request and the disk; pixels protect the worker that will decode the picture, because memory use follows width times height, not file size.

  6. Question 6 of 13 In a fresh Laravel 13 project, where does the local disk write, and who can read it over the web?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: storage/app/private, and nobody without a signed link

    Two disks, two meanings. Choose the private one unless the file is meant for everyone, and remember that moving a file between them changes who can read it.

  7. Question 7 of 13 What does php artisan storage:link create, and what should never be put behind it?
    Show the answer: Question 7 of 13

    Answer: A symbolic link from public/storage to the public disk, which must not hold private files

    Use the public disk for files that are public by nature, such as a school's logo. Anything that belongs to one parent or one teacher stays on the private disk behind a signed link.

  8. Question 8 of 13 What does the query of a temporary URL for a local disk carry?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: An expiry and a signature over the path and that expiry

    A signed link needs no table, which is also its limit: it cannot be revoked one link at a time. Keep the expiry short for that reason, and give a new link each time a page is opened.

  9. Question 9 of 13 A teacher forwards a ten-minute link to a parents' group. What has happened, and what would prevent it?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: Anyone in the group can open the file until it expires; an authorized download route would not

    Choose by what the file is. A short-lived link suits a file a reader may keep; something confidential belongs behind a route that authorizes the caller on every request and streams the file itself.

  10. Question 10 of 13 Which setting does a locally run S3-compatible store usually need that a cloud bucket does not?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: AWS_USE_PATH_STYLE_ENDPOINT set to true

    Keeping development and production on the same driver is what makes the move boring: the same disk name, the same methods, the same signed links, with an endpoint and a path-style flag in the environment file.

  11. Question 11 of 13 A bucket for homework is created with public read access, so links are simple. What is the consequence, and what replaces it?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: Every file is readable by anyone with the URL; keep the bucket private and use temporary URLs

    A private bucket plus presigned links gives the same reader experience with a way to stop handing out access. A public bucket cannot be made private again for copies that are already in somebody's history.

  12. Question 12 of 13 Resizing is moved into a queued job, and the worker still runs out of memory on some uploads. What is the likely cause?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: The pixels were never bounded, so a small file decodes into a very large picture

    A queue moves the cost out of the request; it does not remove it. Bound the pixels in the rules, and give the worker a memory limit and a timeout that match the largest picture you accept.

  13. Question 13 of 13 Why does the job depend on a scanner interface rather than calling a scanning service directly?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: A test can bind a scanner that answers at once, without any service running

    One method, two implementations: the real one in production, a decided answer in tests. The lesson's stand-in refuses a file whose first bytes are a script, which is enough to show the path through the job.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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 named receipt.jpg.
  • Bound the bytes with max('5mb'), the type with File::types([...]), the sender’s file name with extensions:, and a picture’s pixels with a dimensions rule added by sometimes() so documents are not refused for having no pixels.
  • The local disk writes to storage/app/private and putFile stores under forty generated characters, so nothing lands in public/ 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 s3 disk 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.