Why FastAPI Fails to Parse PHP cURL Multi-File Uploads (and How to Fix It) - Featured Image

FastAPI provides a clean and well-designed interface for handling multiple file uploads. However, when the client side switches to PHP cURL, developers often encounter situations where the implementation looks correct but FastAPI fails to parse the uploaded files properly.

In most cases, this is not a bug, but a mismatch between how different languages interpret and construct multipart/form-data payloads.

In this article, I’ll walk through the approaches I tested, explain why they fail, and conclude with two working solutions.

Defining Multiple File Uploads in FastAPI

In FastAPI, defining an endpoint for multiple file uploads is straightforward:

            
                @router.post("/upload")
                async def upload_files(
                    request: Request,
                    files: Annotated[List[UploadFile], File(...)],
                ):
                    pass
            
        

This definition is fully aligned with FastAPI’s official documentation and best practices. Under this setup, FastAPI expects the following behavior:

  • files is a single parameter name
  • The request must contain multiple form fields with the same name: files
  • FastAPI aggregates these fields into a List[UploadFile]

All examples and discussions below assume that the FastAPI definition remains unchanged.

Uploading Files with PHP cURL

After defining the API, the next challenge is sending files from PHP using cURL.

Attempt 1: Using files[0], files[1], files[2]

A common first attempt in PHP looks like this:

                
                    curl_setopt($curl, CURLOPT_POSTFIELDS, [
                        'files[0]' => new CURLFile($filePath1, mime_content_type($filePath1), basename($filePath1)),
                        'files[1]' => new CURLFile($filePath2, mime_content_type($filePath2), basename($filePath2)),
                        'files[2]' => new CURLFile($filePath3, mime_content_type($filePath3), basename($filePath3)),
                    ]);
                
            

From a PHP developer’s perspective, this is perfectly reasonable—it mirrors how arrays are typically handled in PHP forms.

However, the problem is on the FastAPI side:

  • FastAPI does not interpret files[0], files[1], and files[2] as the same field
  • Instead, they are treated as three distinct keys
  • This does not match files: List[UploadFile]

As a result, FastAPI either reports that the files field is missing or fails to parse any uploaded files at all.

Attempt 2: Wrapping Files in an Array

Another common attempt is to group multiple CURLFile objects inside an array:

                
                    curl_setopt($curl, CURLOPT_POSTFIELDS, [
                        'files' => [
                            new CURLFile($filePath1, mime_content_type($filePath1), basename($filePath1)),
                            new CURLFile($filePath2, mime_content_type($filePath2), basename($filePath2)),
                            new CURLFile($filePath3, mime_content_type($filePath3), basename($filePath3)),
                        ],
                    ]);
                
            

Unfortunately, this does not work either—and fails immediately.

Reasons:

  • CURLOPT_POSTFIELDS does not support nested arrays when using CURLFile
  • PHP cURL cannot automatically serialize this structure into valid multipart/form-data
  • PHP provides no native syntax for expressing “multiple fields with the same name”

Recommended Solution: Manually Construct the Multipart Payload

From the experiments above, we can conclude:

  • FastAPI behaves exactly as designed
  • PHP cURL also behaves as documented
  • The real issue is the structural mismatch in how multipart/form-data is constructed

The most reliable solution is to manually assemble the multipart request body in PHP.

Define a Function to Build the Payload

            
                function build_multipart_body($fields, $files, $boundary) {
                    $body = '';

                    // Regular form fields
                    foreach ($fields as $key => $value) {
                        $body .= "--$boundary\r\n";
                        $body .= "Content-Disposition: form-data; name=\"$key\"\r\n\r\n";
                        $body .= "$value\r\n";
                    }

                    // File fields (allow duplicate field names like 'files')
                    foreach ($files as $file) {
                        $fieldName = $file['field'];
                        $filename  = $file['filename'];
                        $mimetype  = $file['mimetype'];
                        $content   = file_get_contents($file['path']);   // For large files, consider streaming instead

                        $body .= "--$boundary\r\n";
                        $body .= "Content-Disposition: form-data; name=\"$fieldName\"; filename=\"$filename\"\r\n";
                        $body .= "Content-Type: $mimetype\r\n\r\n";
                        $body .= $content . "\r\n";
                    }

                    $body .= "--$boundary--\r\n";
                    return $body;
                }
            
        

How to Use It

            
                // boundary
                $boundary = '--------------------------' . microtime(true);

                // Standard form fields
                $formFields = [
                    'foo' => 'bar'
                ];

                // File fields — note that every field name is **files**
                $filesData = [
                    [
                        'field'      => 'files',
                        'path'       => $filePath1,
                        'filename'   => basename($filePath1),
                        'mimetype'   => mime_content_type($filePath1),
                    ],
                    [
                        'field'      => 'files',
                        'path'       => $filePath2,
                        'filename'   => basename($filePath2),
                        'mimetype'   => mime_content_type($filePath2),
                    ],
                    [
                        'field'      => 'files',
                        'path'       => $filePath3,
                        'filename'   => basename($filePath3),
                        'mimetype'   => mime_content_type($filePath3),
                    ]
                ];

                $payload = build_multipart_body($formFields, $filesData, $boundary);
                curl_setopt($curl, CURLOPT_POSTFIELDS, $payload);
                curl_setopt($curl, CURLOPT_HTTPHEADER, [
                    'Accept: application/json',
                    "Content-Type: multipart/form-data; boundary=$boundary"
                ]);
            
        

With this approach, FastAPI correctly receives all uploaded files under the files parameter.

Alternative (Not Recommended): Adjust FastAPI to Match PHP

If manually assembling multipart payloads feels too verbose, another option is to adjust FastAPI to adapt to PHP’s output.

            
                @router.post("/upload")
                async def upload_files(request: Request):
                    form = await request.form()

                    # Manually collect fields with names starting with 'files'
                    files = [val for key, val in form.items() if key.startswith("files")]
            
        

While this works, it is not recommended:

  • It weakens the API contract
  • It makes the backend depend on client-side quirks
  • It deviates from FastAPI’s intended design model

Conclusion

This issue isn’t about FastAPI or PHP being “wrong.” It’s about how multipart/form-data is expressed, and how different ecosystems expect it to look.

If stability and correctness matter, manually constructing the multipart payload is the most robust solution when using PHP cURL with FastAPI for multiple file uploads.