Skip to content

fix: nest the files of a multipart request the way PHP does - #286

Open
ecsol wants to merge 1 commit into
pestphp:5.xfrom
ecsol:fix/multipart-nested-file-names
Open

ecsol wants to merge 1 commit into
pestphp:5.xfrom
ecsol:fix/multipart-nested-file-names

Conversation

@ecsol

@ecsol ecsol commented Oct 14, 2026

Copy link
Copy Markdown

What's wrong

Since #261 a multipart body reaches the application, but a file input with an array or nested name does not. parseMultipartBody() stores each file under its raw part name (src/Drivers/LaravelHttpServer.php#L506), and the docblock above it says so (#L479-L481): "Two parts with the same name keep the last file, and a nested name such as files[avatar] stays flat where PHP would nest it."

So with <input type="file" name="documents[]"> × 2:

real PHP SAPI v5.1.2 test server
array_keys($request->allFiles()) ["documents"] ["documents[]"]
$request->file('documents') [UploadedFile, UploadedFile] null
$request->file('profile.avatar') for name="profile[avatar]" UploadedFile null

Any app that validates documents.* or profile.avatar gets a 422 in a browser test even though the upload works in production. Text fields already nest correctly, because they go through parse_str.

Reproducing it

Without a browser, against v5.1.2:

$body = "--B\r\nContent-Disposition: form-data; name=\"documents[]\"; filename=\"a.txt\"\r\n\r\nfirst\r\n"
      . "--B\r\nContent-Disposition: form-data; name=\"documents[]\"; filename=\"b.txt\"\r\n\r\nsecond\r\n--B--\r\n";
$server = (new ReflectionClass(Pest\Browser\Drivers\LaravelHttpServer::class))->newInstanceWithoutConstructor();
[, $files] = (fn () => $this->parseMultipartBody($body, 'B'))->call($server);

var_dump(array_keys($files));                   // ["documents[]"]
var_dump($files['documents[]']->getClientOriginalName()); // "b.txt" (a.txt is lost)

End to end, in a fresh Laravel 13.36 app with v5.1.2 (PHP 8.4.25, Playwright 1.63.0, macOS):

it('receives both files of documents[]', function () {
    Route::get('/', fn () => '<form method="post" action="/upload" enctype="multipart/form-data">
        <input type="file" id="one" name="documents[]">
        <input type="file" id="two" name="documents[]">
        <button type="submit">Upload</button></form>');
    Route::post('/upload', fn (Request $request) => json_encode(array_keys($request->allFiles())));

    // ...attach two temp files to #one and #two
    visit('/')->attach('#one', $a)->attach('#two', $b)->click('Upload')
        ->assertSee('["documents"]'); // the page shows ["documents[]"]
});

The fix

File parts are collected in a list, and their names are run through parse_str with the list index as the value (documents[]=0&documents[]=1&profile[avatar]=2). The indexes are then swapped for the uploads. The nesting comes from the same function that already handles the text fields, so it matches $_FILES, including [] appends. removeUploads() now walks the nested array.

Tests

The new test in tests/Browser/Webpage/UploadTest.php (two documents[] inputs plus one profile[avatar]) fails on current 5.x and passes with this change. The existing upload and attach tests still pass. pint, rector, phpstan and type coverage (100%) are clean.

Related

🤖 Generated with Claude Code

A file input named `documents[]` or `profile[avatar]` reached the
application under the flat key `documents[]`, and two parts with the
same name kept only the last file, so `$request->file('documents')`
was null. File names are now parsed with parse_str, like the fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant