{"id":351,"date":"2025-08-27T20:10:29","date_gmt":"2025-08-27T20:10:29","guid":{"rendered":"https:\/\/1v0.net\/blog\/?p=351"},"modified":"2025-08-27T20:10:31","modified_gmt":"2025-08-27T20:10:31","slug":"how-to-build-a-secure-file-upload-api-in-laravel","status":"publish","type":"post","link":"https:\/\/1v0.net\/blog\/how-to-build-a-secure-file-upload-api-in-laravel\/","title":{"rendered":"How to Build a Secure File Upload API in Laravel"},"content":{"rendered":"\n<h2 class=\"wp-block-heading\"><strong>How to Build a Secure File Upload API in Laravel<\/strong><\/h2>\n\n\n\n<p>File uploads are a common attack vector. A secure API must validate file size and MIME type, store files outside the public web root, generate safe filenames, optionally virus-scan, and expose only signed URLs for downloads. In this guide you\u2019ll create a hardened upload API using Laravel\u2019s validation, Storage, policies, and (optionally) a queue-powered malware scan.<\/p>\n\n\n\n<div style=\"height:100px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n\n<h2 class=\"wp-block-heading\"><strong>1 &#8211; Configure Storage &amp; App Limits<\/strong><\/h2>\n\n\n\n<p>We\u2019ll store files on the <code>local<\/code> disk (outside <code>public\/<\/code>) and expose them via signed routes. Also ensure PHP upload limits are sane for your use case.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"Bash\" data-shcb-language-slug=\"bash\"><span><code class=\"hljs language-bash\">php artisan storage:link   <span class=\"hljs-comment\"># only if you plan to serve some files via 'public' disk<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">Bash<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">bash<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p><code>storage:link<\/code> is not required for the <code>local<\/code> disk. If you later move to the <code>public<\/code> disk (e.g., for images), the symlink lets the web server reach <code>storage\/app\/public<\/code>. For private downloads we\u2019ll stream files via a controller instead of direct access.<\/p>\n\n\n<!-- DomainException(0): Unknown language: \"dotenv\" --># .env (review these)\nUPLOAD_MAX_FILESIZE=5M\nPOST_MAX_SIZE=6M\nFILESYSTEM_DISK=local\n\n\n<p>Match <code>UPLOAD_MAX_FILESIZE<\/code> and <code>POST_MAX_SIZE<\/code> to your needs (and your server\u2019s <code>php.ini<\/code>). Using the <code>local<\/code> disk keeps files private by default.<\/p>\n\n\n\n\n<div style=\"height:100px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n\n<h2 class=\"wp-block-heading\"><strong>2 &#8211; Migration &amp; Model for Uploaded Files<\/strong><\/h2>\n\n\n\n<p>We\u2019ll track uploaded files in a table with original name, stored path, size, detected MIME, and an owner. We\u2019ll also keep a SHA-256 hash for deduplication and security audits.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php\"><span class=\"hljs-comment\">\/\/ database\/migrations\/2025_08_27_000000_create_uploads_table.php<\/span>\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Illuminate<\/span>\\<span class=\"hljs-title\">Database<\/span>\\<span class=\"hljs-title\">Migrations<\/span>\\<span class=\"hljs-title\">Migration<\/span>;\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Illuminate<\/span>\\<span class=\"hljs-title\">Database<\/span>\\<span class=\"hljs-title\">Schema<\/span>\\<span class=\"hljs-title\">Blueprint<\/span>;\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Illuminate<\/span>\\<span class=\"hljs-title\">Support<\/span>\\<span class=\"hljs-title\">Facades<\/span>\\<span class=\"hljs-title\">Schema<\/span>;\n\n<span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">new<\/span> <span class=\"hljs-class\"><span class=\"hljs-keyword\">class<\/span> <span class=\"hljs-keyword\">extends<\/span> <span class=\"hljs-title\">Migration<\/span> <\/span>{\n    <span class=\"hljs-keyword\">public<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">up<\/span><span class=\"hljs-params\">()<\/span>: <span class=\"hljs-title\">void<\/span> <\/span>{\n        Schema::create(<span class=\"hljs-string\">'uploads'<\/span>, <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-params\">(Blueprint $table)<\/span> <\/span>{\n            $table-&gt;id();\n            $table-&gt;foreignId(<span class=\"hljs-string\">'user_id'<\/span>)-&gt;constrained()-&gt;cascadeOnDelete();\n            $table-&gt;string(<span class=\"hljs-string\">'original_name'<\/span>);\n            $table-&gt;string(<span class=\"hljs-string\">'disk'<\/span>)-&gt;default(<span class=\"hljs-string\">'local'<\/span>);\n            $table-&gt;string(<span class=\"hljs-string\">'path'<\/span>);                <span class=\"hljs-comment\">\/\/ e.g. uploads\/2025\/08\/xyz.pdf<\/span>\n            $table-&gt;string(<span class=\"hljs-string\">'mime'<\/span>, <span class=\"hljs-number\">190<\/span>);           <span class=\"hljs-comment\">\/\/ detected server-side<\/span>\n            $table-&gt;unsignedBigInteger(<span class=\"hljs-string\">'size'<\/span>);    <span class=\"hljs-comment\">\/\/ bytes<\/span>\n            $table-&gt;string(<span class=\"hljs-string\">'sha256'<\/span>, <span class=\"hljs-number\">64<\/span>)-&gt;index(); <span class=\"hljs-comment\">\/\/ content hash<\/span>\n            $table-&gt;boolean(<span class=\"hljs-string\">'is_safe'<\/span>)-&gt;default(<span class=\"hljs-keyword\">true<\/span>); <span class=\"hljs-comment\">\/\/ set false during scan if suspect<\/span>\n            $table-&gt;timestamps();\n        });\n    }\n    <span class=\"hljs-keyword\">public<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">down<\/span><span class=\"hljs-params\">()<\/span>: <span class=\"hljs-title\">void<\/span> <\/span>{\n        Schema::dropIfExists(<span class=\"hljs-string\">'uploads'<\/span>);\n    }\n};<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>This schema captures essential metadata for each upload. The <code>sha256<\/code> allows duplicate detection and forensic checks. <code>is_safe<\/code> can be toggled by a scanner job to quarantine suspicious files.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php\"><span class=\"hljs-comment\">\/\/ app\/Models\/Upload.php<\/span>\n<span class=\"hljs-keyword\">namespace<\/span> <span class=\"hljs-title\">App<\/span>\\<span class=\"hljs-title\">Models<\/span>;\n\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Illuminate<\/span>\\<span class=\"hljs-title\">Database<\/span>\\<span class=\"hljs-title\">Eloquent<\/span>\\<span class=\"hljs-title\">Model<\/span>;\n\n<span class=\"hljs-class\"><span class=\"hljs-keyword\">class<\/span> <span class=\"hljs-title\">Upload<\/span> <span class=\"hljs-keyword\">extends<\/span> <span class=\"hljs-title\">Model<\/span>\n<\/span>{\n    <span class=\"hljs-keyword\">protected<\/span> $fillable = &#91;\n        <span class=\"hljs-string\">'user_id'<\/span>,<span class=\"hljs-string\">'original_name'<\/span>,<span class=\"hljs-string\">'disk'<\/span>,<span class=\"hljs-string\">'path'<\/span>,<span class=\"hljs-string\">'mime'<\/span>,<span class=\"hljs-string\">'size'<\/span>,<span class=\"hljs-string\">'sha256'<\/span>,<span class=\"hljs-string\">'is_safe'<\/span>\n    ];\n\n    <span class=\"hljs-keyword\">public<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">user<\/span><span class=\"hljs-params\">()<\/span>\n    <\/span>{\n        <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">$this<\/span>-&gt;belongsTo(User::class);\n    }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>The model is straightforward, linking each upload to its owner. We\u2019ll use this for authorization and listing endpoints later.<\/p>\n\n\n\n\n<div style=\"height:100px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n\n<h2 class=\"wp-block-heading\"><strong>3 &#8211; Validation Rules for Secure Uploads<\/strong><\/h2>\n\n\n\n<p>Validate both size and type. Prefer <code>mimetypes<\/code> for server-side MIME detection and restrict to a small allow-list.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php\"><span class=\"hljs-comment\">\/**\n * Example rules for PDFs and images only (5 MB max).\n *\/<\/span>\n$rules = &#91;\n    <span class=\"hljs-string\">'file'<\/span> =&gt; &#91;\n        <span class=\"hljs-string\">'required'<\/span>,\n        <span class=\"hljs-string\">'file'<\/span>,\n        <span class=\"hljs-string\">'max:5120'<\/span>, <span class=\"hljs-comment\">\/\/ KB =&gt; 5 MB<\/span>\n        <span class=\"hljs-string\">'mimetypes:application\/pdf,image\/jpeg,image\/png'<\/span>\n    ],\n];<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p><code>max<\/code> is in kilobytes. Using <code>mimetypes<\/code> ensures the server-inspected MIME matches your allow-list, which is safer than extension-only checks. Adjust the list to your needs.<\/p>\n\n\n\n\n<div style=\"height:100px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n\n<h2 class=\"wp-block-heading\"><strong>4 &#8211; Upload Controller (Store Privately + Hash)<\/strong><\/h2>\n\n\n\n<p>This controller validates the upload, stores it with a safe path, computes a hash, and records metadata. We\u2019ll protect the route with <code>auth:sanctum<\/code>.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php\"><span class=\"hljs-comment\">\/\/ app\/Http\/Controllers\/UploadApiController.php<\/span>\n<span class=\"hljs-keyword\">namespace<\/span> <span class=\"hljs-title\">App<\/span>\\<span class=\"hljs-title\">Http<\/span>\\<span class=\"hljs-title\">Controllers<\/span>;\n\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">App<\/span>\\<span class=\"hljs-title\">Models<\/span>\\<span class=\"hljs-title\">Upload<\/span>;\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Illuminate<\/span>\\<span class=\"hljs-title\">Http<\/span>\\<span class=\"hljs-title\">Request<\/span>;\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Illuminate<\/span>\\<span class=\"hljs-title\">Support<\/span>\\<span class=\"hljs-title\">Facades<\/span>\\<span class=\"hljs-title\">Storage<\/span>;\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Illuminate<\/span>\\<span class=\"hljs-title\">Support<\/span>\\<span class=\"hljs-title\">Str<\/span>;\n\n<span class=\"hljs-class\"><span class=\"hljs-keyword\">class<\/span> <span class=\"hljs-title\">UploadApiController<\/span> <span class=\"hljs-keyword\">extends<\/span> <span class=\"hljs-title\">Controller<\/span>\n<\/span>{\n    <span class=\"hljs-keyword\">public<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">store<\/span><span class=\"hljs-params\">(Request $request)<\/span>\n    <\/span>{\n        $data = $request-&gt;validate(&#91;\n            <span class=\"hljs-string\">'file'<\/span> =&gt; &#91;<span class=\"hljs-string\">'required'<\/span>,<span class=\"hljs-string\">'file'<\/span>,<span class=\"hljs-string\">'max:5120'<\/span>,<span class=\"hljs-string\">'mimetypes:application\/pdf,image\/jpeg,image\/png'<\/span>],\n        ]);\n\n        $file = $data&#91;<span class=\"hljs-string\">'file'<\/span>];\n\n        <span class=\"hljs-comment\">\/\/ Generate a safe path (no user-supplied filename)<\/span>\n        $folder = <span class=\"hljs-string\">'uploads\/'<\/span>.now()-&gt;format(<span class=\"hljs-string\">'Y\/m'<\/span>);\n        $filename = Str::uuid().<span class=\"hljs-string\">'.'<\/span>.$file-&gt;guessExtension(); <span class=\"hljs-comment\">\/\/ guessExtension() based on MIME<\/span>\n        $path = $file-&gt;storeAs($folder, $filename, disk: <span class=\"hljs-string\">'local'<\/span>); <span class=\"hljs-comment\">\/\/ private by default<\/span>\n\n        <span class=\"hljs-comment\">\/\/ Read contents for hashing (small\/medium files). For very large files, stream hash.<\/span>\n        $sha256 = hash_file(<span class=\"hljs-string\">'sha256'<\/span>, Storage::disk(<span class=\"hljs-string\">'local'<\/span>)-&gt;path($path));\n\n        $upload = Upload::create(&#91;\n            <span class=\"hljs-string\">'user_id'<\/span>       =&gt; $request-&gt;user()-&gt;id,\n            <span class=\"hljs-string\">'original_name'<\/span> =&gt; $file-&gt;getClientOriginalName(),\n            <span class=\"hljs-string\">'disk'<\/span>          =&gt; <span class=\"hljs-string\">'local'<\/span>,\n            <span class=\"hljs-string\">'path'<\/span>          =&gt; $path,\n            <span class=\"hljs-string\">'mime'<\/span>          =&gt; $file-&gt;getMimeType(),\n            <span class=\"hljs-string\">'size'<\/span>          =&gt; $file-&gt;getSize(),\n            <span class=\"hljs-string\">'sha256'<\/span>        =&gt; $sha256,\n            <span class=\"hljs-string\">'is_safe'<\/span>       =&gt; <span class=\"hljs-keyword\">true<\/span>, <span class=\"hljs-comment\">\/\/ or false until a scanner job verifies<\/span>\n        ]);\n\n        <span class=\"hljs-keyword\">return<\/span> response()-&gt;json(&#91;\n            <span class=\"hljs-string\">'id'<\/span> =&gt; $upload-&gt;id,\n            <span class=\"hljs-string\">'message'<\/span> =&gt; <span class=\"hljs-string\">'Uploaded successfully'<\/span>,\n        ], <span class=\"hljs-number\">201<\/span>);\n    }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Files are stored under <code>storage\/app\/uploads\/YYYY\/MM<\/code> with a UUID filename to avoid collisions and path traversal issues. The DB row ties the file to the user and includes a content hash for later integrity checks or deduplication.<\/p>\n\n\n\n\n<div style=\"height:100px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n\n<h2 class=\"wp-block-heading\"><strong>5 &#8211; Secure Download via Signed Route<\/strong><\/h2>\n\n\n\n<p>Serve private files by streaming them from storage only if the signed URL is valid and the user is authorized.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php\"><span class=\"hljs-comment\">\/\/ app\/Http\/Controllers\/DownloadController.php<\/span>\n<span class=\"hljs-keyword\">namespace<\/span> <span class=\"hljs-title\">App<\/span>\\<span class=\"hljs-title\">Http<\/span>\\<span class=\"hljs-title\">Controllers<\/span>;\n\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">App<\/span>\\<span class=\"hljs-title\">Models<\/span>\\<span class=\"hljs-title\">Upload<\/span>;\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Illuminate<\/span>\\<span class=\"hljs-title\">Http<\/span>\\<span class=\"hljs-title\">Request<\/span>;\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Illuminate<\/span>\\<span class=\"hljs-title\">Support<\/span>\\<span class=\"hljs-title\">Facades<\/span>\\<span class=\"hljs-title\">Gate<\/span>;\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Illuminate<\/span>\\<span class=\"hljs-title\">Support<\/span>\\<span class=\"hljs-title\">Facades<\/span>\\<span class=\"hljs-title\">Storage<\/span>;\n\n<span class=\"hljs-class\"><span class=\"hljs-keyword\">class<\/span> <span class=\"hljs-title\">DownloadController<\/span> <span class=\"hljs-keyword\">extends<\/span> <span class=\"hljs-title\">Controller<\/span>\n<\/span>{\n    <span class=\"hljs-keyword\">public<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">show<\/span><span class=\"hljs-params\">(Request $request, Upload $upload)<\/span>\n    <\/span>{\n        <span class=\"hljs-keyword\">if<\/span> (! $request-&gt;hasValidSignature()) {\n            abort(<span class=\"hljs-number\">403<\/span>);\n        }\n\n        <span class=\"hljs-comment\">\/\/ Optional: owner-only access (or replace with a policy)<\/span>\n        <span class=\"hljs-keyword\">if<\/span> ($request-&gt;user()?-&gt;id !== $upload-&gt;user_id) {\n            abort(<span class=\"hljs-number\">403<\/span>);\n        }\n\n        <span class=\"hljs-keyword\">if<\/span> (! $upload-&gt;is_safe) {\n            abort(<span class=\"hljs-number\">423<\/span>, <span class=\"hljs-string\">'File is quarantined.'<\/span>);\n        }\n\n        <span class=\"hljs-keyword\">return<\/span> Storage::disk($upload-&gt;disk)-&gt;download($upload-&gt;path, $upload-&gt;original_name);\n    }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p><code>hasValidSignature()<\/code> ensures the URL wasn\u2019t tampered with. We also restrict downloads to the owner and block quarantined files. The Storage <code>download()<\/code> helper streams the file with correct headers.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php\"><span class=\"hljs-comment\">\/\/ Generate a temporary signed URL (e.g., in a controller or resource)<\/span>\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Illuminate<\/span>\\<span class=\"hljs-title\">Support<\/span>\\<span class=\"hljs-title\">Facades<\/span>\\<span class=\"hljs-title\">URL<\/span>;\n\n$signedUrl = URL::temporarySignedRoute(\n    <span class=\"hljs-string\">'uploads.show'<\/span>,\n    now()-&gt;addMinutes(<span class=\"hljs-number\">10<\/span>),\n    &#91;<span class=\"hljs-string\">'upload'<\/span> =&gt; $upload-&gt;id]\n);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>A temporary signed URL expires after 10 minutes, reducing link leakage risks. Return this from an API that lists a user\u2019s files when they request a download.<\/p>\n\n\n\n\n<div style=\"height:100px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n\n<h2 class=\"wp-block-heading\"><strong>6 &#8211; Routes (Protected Upload, Signed Download)<\/strong><\/h2>\n\n\n\n<p>Separate API (token-protected) for uploads from signed web routes for downloads. You can also expose a \u201clist my files\u201d endpoint.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php\"><span class=\"hljs-comment\">\/\/ routes\/api.php<\/span>\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">App<\/span>\\<span class=\"hljs-title\">Http<\/span>\\<span class=\"hljs-title\">Controllers<\/span>\\<span class=\"hljs-title\">UploadApiController<\/span>;\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Illuminate<\/span>\\<span class=\"hljs-title\">Support<\/span>\\<span class=\"hljs-title\">Facades<\/span>\\<span class=\"hljs-title\">Route<\/span>;\n\nRoute::middleware(<span class=\"hljs-string\">'auth:sanctum'<\/span>)-&gt;group(<span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-params\">()<\/span> <\/span>{\n    Route::post(<span class=\"hljs-string\">'\/uploads'<\/span>, &#91;UploadApiController::class, <span class=\"hljs-string\">'store'<\/span>])-&gt;name(<span class=\"hljs-string\">'api.uploads.store'<\/span>);\n});<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>The upload route is protected by Sanctum. Only authenticated clients can POST files. Add rate limiting via the <code>throttle<\/code> middleware if needed for abuse control.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php\"><span class=\"hljs-comment\">\/\/ routes\/web.php<\/span>\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">App<\/span>\\<span class=\"hljs-title\">Http<\/span>\\<span class=\"hljs-title\">Controllers<\/span>\\<span class=\"hljs-title\">DownloadController<\/span>;\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Illuminate<\/span>\\<span class=\"hljs-title\">Support<\/span>\\<span class=\"hljs-title\">Facades<\/span>\\<span class=\"hljs-title\">Route<\/span>;\n\nRoute::get(<span class=\"hljs-string\">'\/uploads\/{upload}'<\/span>, &#91;DownloadController::class, <span class=\"hljs-string\">'show'<\/span>])\n    -&gt;middleware(&#91;<span class=\"hljs-string\">'signed'<\/span>,<span class=\"hljs-string\">'auth'<\/span>])\n    -&gt;name(<span class=\"hljs-string\">'uploads.show'<\/span>);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>The download route requires a valid signature and an authenticated session. If you need token-based downloads instead, put it under <code>routes\/api.php<\/code> with <code>auth:sanctum<\/code> and still use signed URLs.<\/p>\n\n\n\n\n<div style=\"height:100px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n\n<h2 class=\"wp-block-heading\"><strong>7 &#8211; Optional: Antivirus Scan with a Queue Job<\/strong><\/h2>\n\n\n\n<p>For higher security, queue a malware scan (e.g., ClamAV) right after upload and quarantine the file until it\u2019s cleared.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php\"><span class=\"hljs-comment\">\/\/ app\/Jobs\/ScanUpload.php<\/span>\n<span class=\"hljs-keyword\">namespace<\/span> <span class=\"hljs-title\">App<\/span>\\<span class=\"hljs-title\">Jobs<\/span>;\n\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">App<\/span>\\<span class=\"hljs-title\">Models<\/span>\\<span class=\"hljs-title\">Upload<\/span>;\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Illuminate<\/span>\\<span class=\"hljs-title\">Bus<\/span>\\<span class=\"hljs-title\">Queueable<\/span>;\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Illuminate<\/span>\\<span class=\"hljs-title\">Contracts<\/span>\\<span class=\"hljs-title\">Queue<\/span>\\<span class=\"hljs-title\">ShouldQueue<\/span>;\n<span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Illuminate<\/span>\\<span class=\"hljs-title\">Support<\/span>\\<span class=\"hljs-title\">Facades<\/span>\\<span class=\"hljs-title\">Storage<\/span>;\n\n<span class=\"hljs-class\"><span class=\"hljs-keyword\">class<\/span> <span class=\"hljs-title\">ScanUpload<\/span> <span class=\"hljs-keyword\">implements<\/span> <span class=\"hljs-title\">ShouldQueue<\/span>\n<\/span>{\n    <span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Queueable<\/span>;\n\n    <span class=\"hljs-keyword\">public<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">__construct<\/span><span class=\"hljs-params\">(public int $uploadId)<\/span> <\/span>{}\n\n    <span class=\"hljs-keyword\">public<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">handle<\/span><span class=\"hljs-params\">()<\/span>: <span class=\"hljs-title\">void<\/span>\n    <\/span>{\n        $upload = Upload::find(<span class=\"hljs-keyword\">$this<\/span>-&gt;uploadId);\n        <span class=\"hljs-keyword\">if<\/span> (! $upload) <span class=\"hljs-keyword\">return<\/span>;\n\n        $path = Storage::disk($upload-&gt;disk)-&gt;path($upload-&gt;path);\n\n        <span class=\"hljs-comment\">\/\/ Pseudocode: replace with actual scanner integration<\/span>\n        <span class=\"hljs-comment\">\/\/ $clean = ClamAV::scan($path);<\/span>\n        $clean = <span class=\"hljs-keyword\">true<\/span>;\n\n        $upload-&gt;update(&#91;<span class=\"hljs-string\">'is_safe'<\/span> =&gt; (bool) $clean]);\n    }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>This job loads the file from disk and runs a scan. If flagged, set <code>is_safe<\/code> to false so downloads are blocked. Wire it in after the upload is created: <code>dispatch(new ScanUpload($upload-&gt;id));<\/code>.<\/p>\n\n\n\n\n<div style=\"height:100px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n\n<h2 class=\"wp-block-heading\"><strong>8 &#8211; UI: Minimal Upload Form with Progress<\/strong><\/h2>\n\n\n\n<p>Here\u2019s a tiny Blade page that uploads to the API using Axios, shows a progress bar, and prints the result. Paste a Bearer token from your Sanctum login first.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-comment\">&lt;!-- resources\/views\/uploads\/test.blade.php --&gt;<\/span>\n@extends('layouts.app')\n\n@section('content')\n<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">\"container\"<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">h1<\/span>&gt;<\/span>Secure Upload Test<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">h1<\/span>&gt;<\/span>\n\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">\"mb-3\"<\/span>&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">label<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">\"form-label\"<\/span>&gt;<\/span>Bearer Token<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">label<\/span>&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">input<\/span> <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">\"token\"<\/span> <span class=\"hljs-attr\">type<\/span>=<span class=\"hljs-string\">\"text\"<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">\"form-control\"<\/span> <span class=\"hljs-attr\">placeholder<\/span>=<span class=\"hljs-string\">\"Paste your token\"<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">\"mb-3\"<\/span>&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">input<\/span> <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">\"file\"<\/span> <span class=\"hljs-attr\">type<\/span>=<span class=\"hljs-string\">\"file\"<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">\"form-control\"<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">\"progress mb-3\"<\/span> <span class=\"hljs-attr\">style<\/span>=<span class=\"hljs-string\">\"height: 8px;\"<\/span>&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">\"bar\"<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">\"progress-bar\"<\/span> <span class=\"hljs-attr\">role<\/span>=<span class=\"hljs-string\">\"progressbar\"<\/span> <span class=\"hljs-attr\">style<\/span>=<span class=\"hljs-string\">\"width: 0%;\"<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">button<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">\"btn btn-theme\"<\/span> <span class=\"hljs-attr\">onclick<\/span>=<span class=\"hljs-string\">\"upload()\"<\/span>&gt;<\/span>Upload<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">button<\/span>&gt;<\/span>\n\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">pre<\/span> <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">\"result\"<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">\"mt-3\"<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">pre<\/span>&gt;<\/span>\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n\n<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">script<\/span> <span class=\"hljs-attr\">src<\/span>=<span class=\"hljs-string\">\"https:\/\/cdn.jsdelivr.net\/npm\/axios\/dist\/axios.min.js\"<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">script<\/span>&gt;<\/span>\n<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">script<\/span>&gt;<\/span><span class=\"javascript\">\n<span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">upload<\/span>(<span class=\"hljs-params\"><\/span>) <\/span>{\n  <span class=\"hljs-keyword\">const<\/span> token = <span class=\"hljs-built_in\">document<\/span>.getElementById(<span class=\"hljs-string\">'token'<\/span>).value;\n  <span class=\"hljs-keyword\">const<\/span> file  = <span class=\"hljs-built_in\">document<\/span>.getElementById(<span class=\"hljs-string\">'file'<\/span>).files&#91;<span class=\"hljs-number\">0<\/span>];\n  <span class=\"hljs-keyword\">if<\/span> (!file) { alert(<span class=\"hljs-string\">'Choose a file'<\/span>); <span class=\"hljs-keyword\">return<\/span>; }\n\n  <span class=\"hljs-keyword\">const<\/span> form = <span class=\"hljs-keyword\">new<\/span> FormData();\n  form.append(<span class=\"hljs-string\">'file'<\/span>, file);\n\n  axios.post(<span class=\"hljs-string\">'\/api\/uploads'<\/span>, form, {\n    <span class=\"hljs-attr\">headers<\/span>: { <span class=\"hljs-attr\">Authorization<\/span>: <span class=\"hljs-string\">`Bearer <span class=\"hljs-subst\">${token}<\/span>`<\/span> },\n    <span class=\"hljs-attr\">onUploadProgress<\/span>: <span class=\"hljs-function\">(<span class=\"hljs-params\">evt<\/span>) =&gt;<\/span> {\n      <span class=\"hljs-keyword\">if<\/span> (evt.total) {\n        <span class=\"hljs-keyword\">const<\/span> percent = <span class=\"hljs-built_in\">Math<\/span>.round((evt.loaded \/ evt.total) * <span class=\"hljs-number\">100<\/span>);\n        <span class=\"hljs-built_in\">document<\/span>.getElementById(<span class=\"hljs-string\">'bar'<\/span>).style.width = percent + <span class=\"hljs-string\">'%'<\/span>;\n      }\n    }\n  }).then(<span class=\"hljs-function\"><span class=\"hljs-params\">res<\/span> =&gt;<\/span> {\n    <span class=\"hljs-built_in\">document<\/span>.getElementById(<span class=\"hljs-string\">'result'<\/span>).textContent = <span class=\"hljs-built_in\">JSON<\/span>.stringify(res.data,<span class=\"hljs-literal\">null<\/span>,<span class=\"hljs-number\">2<\/span>);\n  }).catch(<span class=\"hljs-function\"><span class=\"hljs-params\">err<\/span> =&gt;<\/span> {\n    <span class=\"hljs-keyword\">const<\/span> msg = err.response ? <span class=\"hljs-built_in\">JSON<\/span>.stringify(err.response.data,<span class=\"hljs-literal\">null<\/span>,<span class=\"hljs-number\">2<\/span>) : err.message;\n    <span class=\"hljs-built_in\">document<\/span>.getElementById(<span class=\"hljs-string\">'result'<\/span>).textContent = msg;\n  });\n}\n<\/span><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">script<\/span>&gt;<\/span>\n@endsection<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>The UI posts the file to <code>\/api\/uploads<\/code> with a Bearer token. The progress bar updates as the browser streams the file. On success you\u2019ll get the upload ID to fetch a signed download link later.<\/p>\n\n\n\n\n<div style=\"height:100px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n\n<h2 class=\"wp-block-heading\">Wrapping Up<\/h2>\n\n\n\n<p>You built a secure upload pipeline: strict validation, private storage, hashed contents, owner-based authorization, signed downloads, and an optional antivirus scan with a queue job. This approach prevents unsafe files from being served publicly and gives you full control over who can access which file and for how long.<\/p>\n\n\n\n\n<div style=\"height:100px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n\n<h2 class=\"wp-block-heading\">What\u2019s Next<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"\/blog\/using-laravel-passport-for-advanced-api-authentication\">Using Laravel Passport for Advanced API Authentication<\/a><\/li>\n<li><a href=\"\/blog\/how-to-add-jwt-authentication-to-laravel-apis\">How to Add JWT Authentication to Laravel APIs<\/a><\/li>\n<li><a href=\"\/blog\/integrating-laravel-with-third-party-apis-mail-sms-payment\">Integrating Laravel with Third-Party APIs (Mail, SMS, Payment)<\/a><\/li>\n<\/ul>\n\n","protected":false},"excerpt":{"rendered":"<p>How to Build a Secure File Upload API in Laravel File uploads are a common attack vector. A secure API must validate file size and MIME type, store files outside the public web root, generate safe filenames, optionally virus-scan, and expose only signed URLs for downloads. In this guide you\u2019ll create a hardened upload API [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":355,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7],"tags":[25,22,55],"class_list":["post-351","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-laravel","tag-api","tag-security","tag-uploads"],"_links":{"self":[{"href":"https:\/\/1v0.net\/blog\/wp-json\/wp\/v2\/posts\/351","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/1v0.net\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/1v0.net\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/1v0.net\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/1v0.net\/blog\/wp-json\/wp\/v2\/comments?post=351"}],"version-history":[{"count":1,"href":"https:\/\/1v0.net\/blog\/wp-json\/wp\/v2\/posts\/351\/revisions"}],"predecessor-version":[{"id":354,"href":"https:\/\/1v0.net\/blog\/wp-json\/wp\/v2\/posts\/351\/revisions\/354"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/1v0.net\/blog\/wp-json\/wp\/v2\/media\/355"}],"wp:attachment":[{"href":"https:\/\/1v0.net\/blog\/wp-json\/wp\/v2\/media?parent=351"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/1v0.net\/blog\/wp-json\/wp\/v2\/categories?post=351"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/1v0.net\/blog\/wp-json\/wp\/v2\/tags?post=351"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}