<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ze Horta</title>
    <description>The latest articles on DEV Community by Ze Horta (@zhoorta).</description>
    <link>https://dev.to/zhoorta</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4171712%2Ff0948dbc-fd21-4b2e-8af7-2a985cd3735a.png</url>
      <title>DEV Community: Ze Horta</title>
      <link>https://dev.to/zhoorta</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zhoorta"/>
    <language>en</language>
    <item>
      <title>Resumable uploads in Laravel without a package: chunks, offsets and a queue job</title>
      <dc:creator>Ze Horta</dc:creator>
      <pubDate>Thu, 08 Oct 2026 16:18:26 +0000</pubDate>
      <link>https://dev.to/zhoorta/resumable-uploads-in-laravel-without-a-package-chunks-offsets-and-a-queue-job-4nc6</link>
      <guid>https://dev.to/zhoorta/resumable-uploads-in-laravel-without-a-package-chunks-offsets-and-a-queue-job-4nc6</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; send a file in 5 MB chunks, let the &lt;em&gt;server's&lt;/em&gt; offset decide where to continue, and do the slow final copy in a queue job on its own queue connection, with a &lt;code&gt;retry_after&lt;/code&gt; longer than the job. That is the whole design, and it needs no package.&lt;/p&gt;

&lt;p&gt;Uploading a 2 GB file through a normal PHP form is a bad idea. A proxy times out, PHP's limits get in the way, and one dropped connection at 95% means starting again. When I built a self-hosted file server (Laravel, Livewire, Flux UI) I wanted uploads that survive all of that, including a final copy to a remote disk (SFTP, S3) that can take minutes.&lt;/p&gt;

&lt;p&gt;If you need interoperability with existing clients, the &lt;a href="https://tus.io" rel="noopener noreferrer"&gt;tus protocol&lt;/a&gt; is the standard and there are Laravel packages for it. What follows is a simpler, application-specific protocol that I could test end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The protocol: four endpoints
&lt;/h2&gt;

&lt;p&gt;All uploads, big or small, use the same flow, so there is no separate "simple" path to maintain.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;POST /uploads&lt;/code&gt; with the file name, size and a fingerprint. The server creates an &lt;code&gt;uploads&lt;/code&gt; row and answers with an &lt;code&gt;id&lt;/code&gt;, the current &lt;code&gt;offset&lt;/code&gt; (0) and the &lt;code&gt;chunk_size&lt;/code&gt;. Starting the same file again resumes the unfinished upload instead of creating a new one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PATCH /uploads/{id}&lt;/code&gt; with the raw chunk as the request body and an &lt;code&gt;Upload-Offset&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /uploads/{id}&lt;/code&gt; returns the state: &lt;code&gt;receiving&lt;/code&gt;, &lt;code&gt;processing&lt;/code&gt;, &lt;code&gt;done&lt;/code&gt; or &lt;code&gt;failed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DELETE /uploads/{id}&lt;/code&gt; cancels.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Chunks are appended to a temporary file named after the upload id. The default chunk size is 5 MB, so each request only has to fit your proxy's body limit, not the whole file. &lt;code&gt;upload_max_filesize&lt;/code&gt; stops mattering because the body is not a form upload, but keep &lt;code&gt;post_max_size&lt;/code&gt; and your proxy's body limit above the chunk size.&lt;/p&gt;

&lt;h2&gt;
  
  
  The client: ask the server where you are
&lt;/h2&gt;

&lt;p&gt;The browser slices the file and sends one chunk at a time. The key idea is that the client never trusts its own idea of progress. The server's offset is the truth.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chunk_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/uploads/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PATCH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/octet-stream&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Upload-Offset&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Out of step: the server says where it really is, and we carry on from there.&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a request fails, the client waits (1, 2, 4 seconds and so on, up to five retries), asks &lt;code&gt;GET /uploads/{id}&lt;/code&gt; for the real offset and continues from it. That one rule gives you resume after a network drop, after a tab reload (the fingerprint, here the file's last-modified time, finds the unfinished upload) and after a duplicated request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The server: one writer at a time
&lt;/h2&gt;

&lt;p&gt;Two requests for the same upload must never write at the same time, and a chunk must only be accepted at the exact offset the server expects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"upload:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$upload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$lock&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$upload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$upload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Upload-Offset'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;is_numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$header&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Wrong offset, or already handed over: tell the client where we are.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$upload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isReceiving&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$offset&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$upload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$upload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// append the chunk to the temporary file at that offset ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$lock&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;409&lt;/code&gt; carrying the real state is not an error to the client. It is how it finds its place. I also refuse a chunk that would go past the declared size (&lt;code&gt;413&lt;/code&gt;), and every chunk request checks that the upload belongs to the current user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finishing: do the slow part in a queue job
&lt;/h2&gt;

&lt;p&gt;When the last chunk arrives, the request does one thing: it marks the upload &lt;code&gt;processing&lt;/code&gt;, queues a job and answers &lt;code&gt;202&lt;/code&gt; at once. The job hashes the file (SHA-256), sniffs the MIME type from the content (the client's claim is ignored), copies it to its disk under a random key and creates the database record.&lt;/p&gt;

&lt;p&gt;Why not in the request? On a remote disk the final copy can take minutes, and a proxy will cut the request long before that. Meanwhile the browser shows "Storing the file…", starts sending the next file, and polls &lt;code&gt;GET /uploads/{id}&lt;/code&gt; (every second at first, slowing to five) until the state says &lt;code&gt;done&lt;/code&gt; or &lt;code&gt;failed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The job has two unusual settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FinalizeUpload&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$tries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// a half-finished copy is cleaned up; the user retries&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;21600&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// six hours&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$failOnTimeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$uploadId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;onConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'uploads'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The gotcha that cost me the most time: &lt;code&gt;retry_after&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;A queue worker puts a job back on the queue if it has not finished within the connection's &lt;code&gt;retry_after&lt;/code&gt; seconds. The default is 90. A copy that legitimately takes an hour would be picked up and started a second time while the first is still running.&lt;/p&gt;

&lt;p&gt;The fix is a separate queue connection just for these jobs, with a &lt;code&gt;retry_after&lt;/code&gt; larger than the job's timeout, and a worker started on that connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/queue.php&lt;/span&gt;
&lt;span class="s1"&gt;'uploads'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'database'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'table'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'jobs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'uploads'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'retry_after'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;21700&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// above the job's 21600 s timeout&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan queue:work uploads &lt;span class="nt"&gt;--queue&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;uploads &lt;span class="nt"&gt;--tries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first &lt;code&gt;uploads&lt;/code&gt; is the connection name, not the queue name. A worker started without it would use the default connection and its 90-second window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other things that bit me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Clean up on failure.&lt;/strong&gt; Note the destination key on the upload row before you write, so a crash halfway can still remove the partial file. A failed job deletes the temporary file and any file that no database record uses, and marks the upload &lt;code&gt;failed&lt;/code&gt; with a reason the browser can show next to a Retry button.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quota.&lt;/strong&gt; I check it when the upload starts and again, inside a transaction that locks the user's row, when the file is stored. Parallel uploads can each pass the first check, and the second check keeps the books right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temporary space.&lt;/strong&gt; The temp folder needs room for the largest file times the uploads in flight, and the target disk needs the file again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploys.&lt;/strong&gt; Workers keep the old code in memory, so restart them after every deploy. A scheduled prune command removes unfinished uploads older than a day and gives up on jobs that died.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxy limits.&lt;/strong&gt; The proxy's body limit and timeout only have to cover one chunk. Long timeouts are for downloads, not uploads.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would still do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If you need to resume across different clients and tools, use tus instead of a custom protocol.&lt;/li&gt;
&lt;li&gt;Hash on the client if you want to skip uploads of files you already have. I did not.&lt;/li&gt;
&lt;li&gt;I tested the remote path against one SFTP server (a Hetzner Storage Box) and a local OpenSSH server, not against a real S3 service, so treat that part as less proven.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full implementation (controller, job, the browser store) is open source in the project where I built this: &lt;a href="https://github.com/zhoorta/ferrite" rel="noopener noreferrer"&gt;Ferrite&lt;/a&gt;. The upload design is written up in &lt;code&gt;docs/uploads.md&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;*Disclosure: I wrote the code. An AI assistant helped me draft and edit this article, and I checked every claim against the code.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>livewire</category>
      <category>queues</category>
    </item>
  </channel>
</rss>
