Skip to content

Parse multipart/form-data and forward uploaded files in LaravelHttpServer#223

Open
henryavila wants to merge 1 commit into
pestphp:4.xfrom
henryavila:feature/parse-uploaded-files-in-laravel-http-server
Open

Parse multipart/form-data and forward uploaded files in LaravelHttpServer#223
henryavila wants to merge 1 commit into
pestphp:4.xfrom
henryavila:feature/parse-uploaded-files-in-laravel-http-server

Conversation

@henryavila
Copy link
Copy Markdown

@henryavila henryavila commented May 26, 2026

Summary

LaravelHttpServer::handleRequest() was passing [] to the $files argument of Symfony\Component\HttpFoundation\Request::create() with a // @TODO files... placeholder. This means: any browser test that drives a real <input type=file> through Playwright ($page->attach(...)) has the file written to the input and the change event fires correctly, but the Laravel kernel never sees the file$request->file('avatar') returns null and $request->allFiles() is empty.

The existing tests/Browser/Webpage/AttachTest.php only asserts on document.querySelector("input[name=avatar]").files[0].name (client-side DOM), which masked the gap: from a contributor's POV the API looks complete because the test passes, but no real Laravel upload pipeline can actually be exercised end-to-end.

What this PR does

  1. Implements parseMultipartBody() in LaravelHttpServer. It is invoked when the request Content-Type starts with multipart/form-data. The parser:

    • Extracts the boundary from the Content-Type header.
    • Splits the body byte-safe (plain explode/substr/strpos, never mb_*) so binary uploads — .xlsx, .zip, images, PDFs — survive without UTF-8 corruption.
    • For each part, parses Content-Disposition to extract the field name and optional filename=.
    • File parts are written to tempnam(sys_get_temp_dir(), 'pest_browser_upload_') and wrapped in Symfony\Component\HttpFoundation\File\UploadedFile constructed with $test: true — Symfony then skips is_uploaded_file(), which only returns true for real SAPI uploads.
    • Non-file parts populate $parameters (so $request->input('note') works alongside $request->file('avatar')).
    • Bracket notation (tags[], user[name], files[0]) is honored by delegating to parse_str() via http_build_query(); UploadedFile instances are injected into the parsed structure after the round-trip (since parse_str() only preserves scalars).
  2. Wires the result into Request::create() — replaces the [], // @TODO files... call site with the parsed $files array. multipart/form-data is now a recognized branch alongside the existing application/x-www-form-urlencoded parsing.

  3. Adds tests/Browser/Webpage/AttachServerSideTest.php — three tests that close the gap:

    • forwards a single uploaded file to the Laravel request — name + size + raw content visible server-side after attach() + submit.
    • preserves binary file content (no UTF-8 corruption) — bytes including 0xFF, 0xFE, 0xC0, 0x00, 0x0A, 0x0D survive intact; regression guard against any future mb_* usage in the parser.
    • mixes file and non-file fields in the same multipart request$request->file('avatar') and $request->input('note') both populated from the same POST.

    These tests use fetch() for the form submit because they need to display the server's response in the same page (for assertSee to work); the underlying multipart upload pipeline is identical to a native form submit.

Real-world motivation

Filament v4 FileUpload (which wraps FilePond) + Livewire's batch upload pipeline could not be tested with this plugin because Livewire's _finishUpload($name, $tmpPath = [], false) received an empty array and threw Undefined array key 0 in WithFileUploads.php:49. After investigation, the FilePond JS bridge correctly POSTs the multipart payload — the server just dropped the file before the Laravel kernel ran. This patch makes that pipeline testable end-to-end.

Test plan

  • vendor/bin/pest tests/Browser/Webpage/AttachServerSideTest.php — 3 new tests pass.
  • vendor/bin/pest tests/Browser/Webpage/AttachTest.php — existing tests still pass (no regression).
  • vendor/bin/pest tests/Browser/ — full suite: 239 passed, 1 skipped (platform-specific, unrelated), 0 failed.
  • CI on this PR.

Notes

  • The temp files written under sys_get_temp_dir() are not actively cleaned up by this patch. Symfony's UploadedFile is constructed in test mode ($test: true) so move/store operations work normally; the OS reclaims /tmp on reboot, and individual test runs are short-lived. If you'd prefer explicit cleanup tied to the request lifecycle, happy to add it.
  • parseMultipartBody() aims for correctness with the common shapes (name, name[], name[key], name[0]) rather than the exhaustive RFC 7578 surface (e.g. multipart/mixed for arrays). Same scope as PHP's built-in SAPI parser for $_FILES. Happy to extend if needed.

`LaravelHttpServer::handleRequest()` was passing `[]` to the `$files`
argument of `Symfony\Component\HttpFoundation\Request::create()` with a
`// @todo files...` placeholder. Any test that drove a real upload
through Playwright (`->attach()`) would have the file written to the
`<input>` and the `change` event dispatched correctly, but the Laravel
kernel would never see the file: the existing `Tests/Browser/Webpage/
AttachTest.php` only asserts on `input.files[0].name` client-side,
which masked the gap.

This commit implements `parseMultipartBody()` invoked when the request
content type starts with `multipart/form-data`. It is byte-safe (uses
plain string functions, never `mb_*`) so binary uploads — .xlsx, .zip,
images, PDFs — survive the parse without UTF-8 corruption. Each file
part is written to a temp file in `sys_get_temp_dir()` and wrapped in
a Symfony `UploadedFile` constructed with `$test: true` so it bypasses
`is_uploaded_file()` (which only returns true for real SAPI uploads).

PHP's bracket-notation for nested fields (`tags[]`, `user[name]`,
`files[0]`) is honored for both parameters and files, delegating the
nesting semantics to `parse_str()` via `http_build_query()`. `UploadedFile`
instances are injected into the parsed structure after the round-trip
since `parse_str()` only preserves scalars.

Three new tests cover the end-to-end pipeline:

- single file: client name + size + raw content visible server-side
- binary content: bytes including 0xFF, 0xFE, 0xC0, 0x00, 0x0A, 0x0D
  survive intact (regression guard against any future mb_* usage)
- mixed: file + non-file fields in the same request, both visible

Real-world motivation: Filament v4 FileUpload (FilePond) + Livewire
batch import flow could not be tested via this plugin because Livewire's
`_finishUpload($name, $tmpPath = [], false)` received an empty array and
threw `Undefined array key 0`. The FilePond JS bridge correctly POSTs
the multipart payload; the server just dropped the file. This patch
makes that pipeline testable end-to-end.
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