File uploads

Quotas, judging files by their bytes, storing them safely, and the cleanup contract.

Three things about an upload are attacker-controlled

The filename, the declared media type, and the bytes. None may be trusted, and the API is shaped so that trusting them by accident is difficult.

The form

Template
<form method="post" action="/avatar" enctype="multipart/form-data">
    <input type="hidden" name="_token" value="{{ $csrfToken }}">
    <input type="file" name="avatar" accept="image/png,image/jpeg,image/webp">
    <button type="submit">Upload</button>
</form>

enctype="multipart/form-data" is required — without it the browser sends only the filename.

The handler

PHP
<?php
final class StoreAvatarController implements Handler
{
    private const ALLOWED = [
        'image/png' => 'png',
        'image/jpeg' => 'jpg',
        'image/webp' => 'webp',
    ];

    public function handle(ServerRequest $request): Response
    {
        $file = $request->file('avatar');

        if ($file === null || !$file->isValid()) {
            return $this->back($file?->error->message() ?? 'Choose a file.');
        }

        // Judged by contents, never by name or declared type.
        $extension = $file->extensionFromContents(self::ALLOWED);

        if ($extension === null) {
            return $this->back('That is not a PNG, JPEG or WebP image.');
        }

        // A name you generate — not one the client chose.
        $file->moveTo($this->directory, sprintf('user-%d.%s', $userId, $extension));

        return Response::redirect('/avatar');
    }
}

Quotas are required

PHP
<?php
use PhpOrbit\Http\Upload\UploadQuotas;

new UploadQuotas(
    maxFileBytes: 2 * 1024 * 1024,
    maxTotalBytes: 8 * 1024 * 1024,
    maxFiles: 5,
    maxFieldBytes: 64 * 1024,
    maxParts: 50,
);

UploadQuotas::permissive();   // 32 MB per file, 64 MB total, 20 files

An upload endpoint without quotas is a denial-of-service primitive: anyone can post a body large enough to exhaust disk or memory, and repeat it. Quotas are therefore required to parse at all, and the defaults are small enough to be safe on a machine nobody has tuned.

.env
UPLOAD_MAX_BYTES=1048576

Judging a file

PHP
<?php
$file->clientFilename;      // "photo.png"        — what the browser claimed
$file->clientMediaType;     // "image/png"        — what the browser claimed
$file->size;                // bytes actually received
$file->error;               // UploadError enum

$file->detectedType();      // "image/png" — sniffed from the bytes. Use this.
$file->hasTypeIn(['image/png', 'image/jpeg']);
$file->extensionFromContents(['image/png' => 'png']);
Only detectedType() should gate a decision

A file named avatar.png and declared image/png can still be a PHP script. detectedType() inspects the actual bytes with finfo; the filename and declared type are recorded for display and nothing else.

Errors are values, not exceptions

PHP
<?php
use PhpOrbit\Http\Upload\UploadError;

match ($file->error) {
    UploadError::None => null,
    UploadError::NoFile => 'No file was selected.',
    UploadError::TooLarge => 'The file is larger than this endpoint accepts.',
    UploadError::Partial => 'The upload was interrupted before it finished.',
    UploadError::TooMany => 'Too many files were sent at once.',
    UploadError::CannotWrite => 'The server could not store the upload.',
};

$file->error->message();   // the same text, ready to show

A user picking a file that is too large is ordinary form input, so the handler shows a message rather than catching a throwable.

Storing

PHP
<?php
$path = $file->moveTo($directory, 'user-42.png');

moveTo() refuses a name containing a path separator rather than quietly reducing it, refuses hidden names beginning with a dot, and confirms the resolved destination is a direct child of the directory it was given. Files are stored at mode 0640 — uploads are data, never programs.

If you must use the client's name, launder it first:

PHP
<?php
$file->safeName();               // "my-holiday-photo.png"
$file->safeName('attachment');   // fallback when nothing usable survives

It strips directories and backslashes, collapses everything outside [A-Za-z0-9._-], and refuses a leading dot so an upload cannot become .htaccess. It is still not a substitute for generating your own name — two users can upload photo.png.

Reading without storing

PHP
<?php
$csv = $file->contents();   // throws if invalid or already moved

The cleanup contract

Temporary files are discarded for you

The kernel schedules cleanup when the request scope opens, so any upload not moved is deleted when the request ends — including when the handler throws. An application that forgot to register an upload-handling layer does not slowly fill its temp directory.

PHP
<?php
$file->wasMoved();   // true once moveTo() succeeded; discard() then does nothing
$file->discard();    // explicit early cleanup, safe to call twice

The one case where you own it: if you construct a MultipartParser yourself, outside an Application, nothing schedules the cleanup and you must call discard().

Where to put the files

Not in the document root

A file under public/ is served by nginx or Apache directly, and whether it executes depends on their configuration rather than yours. Store uploads outside the web root and serve them through a route that checks permissions and sets the type.

PHP
<?php
$routes->get('/avatars/{id:\d+}', ServeAvatarController::class, 'avatars.show');
PHP
<?php
return Response::make(Status::Ok, (string) file_get_contents($path))
    ->withHeader('Content-Type', 'image/png')
    ->withHeader('Content-Disposition', 'inline; filename="avatar.png"')
    ->withHeader('Cache-Control', 'private, max-age=3600');

How each target decodes uploads

TargetDecoded by
nginx+FPM, Apache, FrankenPHPPHP itself, into $_FILES; FpmSapi adapts it
./orbit serveMultipartParser, from the body it read

Both paths converge on the same UploadedFile objects, so your handler is identical. One difference matters: files PHP created are moved with move_uploaded_file(), which additionally verifies the source really was an upload for this request. UploadedFile tracks which kind it holds and picks the right call.

Two current limits

  • Array-style inputs (name="photos[]") are not supported. FpmSapi skips them rather than half-supporting PHP's transposed $_FILES arrays. Use distinct field names.
  • The built-in server decodes from memory, which bounds upload size there. That is deliberate for a development server; the production targets stream to disk before PHP sees the request.