Let's Connect

Server room racks with status lights, representing backend file storage infrastructure

The fastest way to hand an attacker a shell on your server is an unrestricted upload endpoint. If a user can POST a `.php` file and then browse to it, that is remote code execution. Secure file uploads PHP and Laravel apps both demand the same discipline, and it comes down to one rule you enforce everywhere: never trust the client. Not the filename, not the `Content-Type` header, not the extension. Validate against an allow-list of extensions backed by content-verified MIME checks, store the file outside the web root under a name you generate, and re-encode images so any embedded payload dies on write. I have cleaned up after the alternative more than once, and it is never a quick fix.

Why is an upload endpoint so dangerous?

Two attacks account for most of the damage I see. The first is RCE: the attacker uploads `shell.php`, your code saves it under the public web root with its original name, and a request to `/uploads/shell.php` runs it. The second is stored XSS via an SVG. An SVG is XML, and XML can carry a `<script>` element. Serve that SVG inline and every visitor who opens it runs the attacker's JavaScript in your origin. Both come from the same mistake: treating attacker-supplied bytes as if they were a harmless image.

  • A `.php`, `.phtml`, or `.phar` file in an executable directory becomes RCE the moment it is requested.
  • An SVG or HTML file served with an inline `Content-Type` becomes stored XSS.
  • A crafted filename like `../../config/app.php` is a path-traversal write that can overwrite real files.
  • A `shell.php.jpg` name defeats any check that only inspects the last extension on a misconfigured server.
  • A polyglot file (valid GIF header, PHP body) slips past naive magic-byte checks while still executing as PHP.

How do I validate the file correctly?

Deny-lists do not work here. You will never enumerate every dangerous extension, and the server's handler mapping is what actually decides execution. Use an allow-list instead, and verify the real content rather than the label. In Laravel 12, the `mimes` rule guesses the MIME type from the file's actual contents, while the `extensions` rule validates the user-assigned extension. You want both: `extensions` catches the double-extension trick, and `mimes` catches the spoof where someone renames `shell.php` to `avatar.jpg` and sets `Content-Type: image/jpeg` by hand.

app/Http/Requests/AvatarUploadRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class AvatarUploadRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'avatar' => [
                'required',
                'file',
                'max:2048', // kilobytes
                // 'image' rejects SVG by default (XSS). Do NOT add :allow_svg.
                'image',
                // Content-verified MIME check (guesses from the bytes):
                'mimes:jpg,jpeg,png,webp',
                // User-assigned extension check (kills shell.php.jpg):
                'extensions:jpg,jpeg,png,webp',
            ],
        ];
    }
}

A note on the SVG line above: Laravel's `image` rule deliberately excludes SVG, because there is no safe way to validate arbitrary XML as an image. Allowing it requires the explicit `image:allow_svg` flag, which you should not set. If you genuinely need vector uploads, that is a separate hardening problem — run the markup through a dedicated server-side sanitizer like `enshrined/svg-sanitize` — not a validation flag you flip on.

Where should uploaded files actually live?

Not in a directory PHP can execute, and ideally not on the application server at all. Laravel's default `local` disk roots at `storage/app/private`, which sits outside the public web root — keep it that way. Better still, push uploads to object storage like S3 and serve them through short-lived signed URLs, so the bytes never pass through a PHP-aware handler. The other half of this is the filename: let the framework generate a random name via `store()` or `hashName()` and derive the extension from the verified type. Never persist `getClientOriginalName()` to disk; that string is where traversal sequences and double extensions hide.

app/Http/Controllers/AvatarController.php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\AvatarUploadRequest;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Laravel\Facades\Image;

class AvatarController extends Controller
{
    public function store(AvatarUploadRequest $request)
    {
        $file = $request->file('avatar');

        // Re-encode through GD/Imagick: strips EXIF, scripts, and any
        // appended payload. The output is a freshly drawn JPEG.
        $clean = Image::read($file->getRealPath())
            ->scaleDown(width: 1024)
            ->toJpeg(quality: 85);

        // We generate the name ourselves; the client name never touches disk.
        $path = 'avatars/' . $request->user()->id . '/'
            . bin2hex(random_bytes(16)) . '.jpg';

        // 'private' visibility on S3 means it is NOT publicly readable.
        Storage::disk('s3')->put($path, (string) $clean, 'private');

        $request->user()->update(['avatar_path' => $path]);

        return response()->json(['stored' => true]);
    }

    public function show()
    {
        // Read the stored path from the authenticated user, never from
        // a route parameter the client controls.
        $path = request()->user()->avatar_path;

        // Short-lived signed URL: the object stays private otherwise.
        $url = Storage::disk('s3')->temporaryUrl(
            $path,
            now()->addMinutes(5),
            ['ResponseContentDisposition' => 'attachment; filename="avatar.jpg"']
        );

        return redirect()->away($url);
    }
}
Colorful lines of source code on a dark editor screen
Validation and storage logic is where most upload bugs hide. Re-encoding the bytes is the step people skip.

How do I neutralize the content itself?

Validation tells you what a file claims to be; re-encoding makes it true. Passing an uploaded image through GD or Imagick — the `Image::read()->toJpeg()` call above — discards the original byte stream entirely and writes a brand-new image from the decoded pixels. A PHP payload appended after the JPEG end marker, a polyglot header, embedded EXIF JavaScript: all gone, because none of it survives a decode-and-redraw. For non-image types you cannot re-encode, the defense shifts to delivery — serve every download with `Content-Disposition: attachment` and an explicit, safe `Content-Type` so the browser saves the file instead of rendering or executing it.

Treat every uploaded byte as hostile until you have rewritten it yourself. Validation narrows the input; re-encoding and signed delivery are what actually contain it.Md Raihan Hasan

One infrastructure-level safeguard ties it together: make sure the directory you write to can never run PHP. If a reverse proxy fronts the app, that is the place to strip script execution for the uploads path — I cover the proxy mechanics in Nginx reverse proxy configuration explained. And because uploads are one input among many, they belong on the broader Laravel security checklist rather than being treated as a one-off.

What about files I have to parse, not just store?

Storing a file safely is not the same as processing it safely. The moment you hand an upload to a library — a spreadsheet reader, a PDF parser, an image resizer — you inherit that library's vulnerabilities. Spreadsheet parsers in particular have a long history of XXE and formula-injection bugs that turn a benign-looking `.xlsx` into remote code execution through the parsing library, so keep dependencies patched and parse untrusted documents in a sandboxed worker where you can. The principle is constant: a file you only store needs a clean copy on disk; a file you parse needs a hardened parser behind it too.

Secure file uploads in PHP are not one clever check; they are a chain. Allow-list the extension and verify the bytes, generate your own filename, store outside the web root or on object storage with private visibility, re-encode images to strip payloads, and deliver everything as a signed download with a forced `Content-Disposition`. Break any single link and the others still hold. Skip the chain entirely and you are one `curl` away from someone else running your code. Build it once, wrap it in a Form Request and a dedicated service, and reuse it on every endpoint that touches a file — consistency is what keeps it secure.