Extensions

Upload

Signed single-file upload ingress for FrankenPHP applications.

Overview

Pogo Upload lets PHP applications create signed upload intents while a Caddy/Go handler receives the raw request body, enforces limits, writes to storage, computes a checksum, and notifies a PHP worker when the upload completes or fails.

It is for large or controlled single-file uploads. It is not a file manager, media processor, CDN, object-storage abstraction, virus scanner, or replacement for normal framework uploads for small forms.

Status and fit

Pogo Upload is experimental. Its v1 module has a local filesystem backend and process-local progress tracking.

Use it when:

  • A normal authenticated controller should authorize and create upload intents.
  • PHP should avoid spending request time moving large upload bodies.
  • One raw file per request is enough.
  • Your completion handler can be idempotent.

Avoid it when:

  • You need multipart form parsing.
  • You need durable distributed progress.
  • You need antivirus scanning, media processing, or CDN publishing in the module.
  • Your framework needs to receive an UploadedFile object directly.

Build

Compile the module into FrankenPHP:

Terminal
xcaddy build \
  --with github.com/dunglas/frankenphp@v1.12.4 \
  --with github.com/dunglas/frankenphp/caddy@v1.12.4 \
  --with github.com/y-l-g/upload/module@master

Caddy configuration

pogo_upload has two parts: a global app that defines stores, and an HTTP handler that accepts signed upload URLs for one store.

Caddyfile
{
  frankenphp

  pogo_upload {
    store default {
      worker public/upload-worker.php
      signing_secret {$POGO_UPLOAD_SECRET}

      backend local {
        root storage/app/pogo-uploads
      }

      token_ttl 15m
      max_upload_bytes 1073741824
      max_concurrency 32
      read_timeout 30s
      complete_timeout 10s
      progress_ttl 10m
    }
  }
}

:80 {
  handle /_pogo/upload/* {
    pogo_upload default
  }

  php_server
}

Store directives:

DirectiveRequiredDefaultDescription
workerYesnonePHP worker script that receives completion and failure events.
signing_secretYesnoneSecret used to sign upload URLs. Keep it stable across reloads and replicas.
backend localYeslocalThe v1 storage backend.
rootYesnoneLocal backend root directory.
token_ttlNo15mDefault signed URL lifetime.
max_upload_bytesNo1073741824Store-level hard upload limit.
max_concurrencyNo32Max in-flight uploads for the store.
read_timeoutNo30sMax idle time while reading the request body.
complete_timeoutNo10sMax wait for the PHP completion worker.
progress_ttlNo10mTime finished progress records remain visible.

Application integration

The current repository exposes native PHP functions directly. Framework services can wrap these functions, but no Laravel or Symfony upload package is shipped here yet.

Create an upload intent from a normal authenticated controller:

$intent = pogo_upload_create([
    'key' => 'users/'.$userId.'/avatars/'.$uuid.'.jpg',
    'filename' => 'avatar.jpg',
    'content_types' => ['image/jpeg', 'image/png'],
    'max_bytes' => 5242880,
    'overwrite' => false,
    'expires_in' => 900,
    'metadata' => [
        'user_id' => (string) $userId,
        'purpose' => 'avatar',
    ],
]);

The client uploads one raw file body to the returned url with the returned HTTP method.

PHP API

pogo_upload_create(array $intent, string $store = 'default'): array;
pogo_upload_progress(string $uploadId, string $store = 'default'): ?array;
pogo_upload_cancel(string $uploadId, string $store = 'default'): bool;
pogo_upload_status(?string $store = null): string;

Intent fields:

FieldRequiredDescription
keyYesBackend object key. Must be relative and normalized.
filenameNoOriginal filename for application metadata.
content_typesNoAccepted request Content-Type values.
max_bytesYesPositive maximum body size.
overwriteNoDefaults to false; rejects existing final keys when false.
expires_inNoSigned URL lifetime in seconds. Defaults to store token_ttl.
metadataNoString map returned unchanged in worker events.

pogo_upload_status() returns JSON with readiness, configured limits, active upload counts, completed/failed/cancelled counters, token/content-type/size rejections, bytes received, backend failures, and worker event failures.

Worker events

The worker is a normal FrankenPHP worker script that receives JSON and returns JSON.

Completed event:

{
  "type": "completed",
  "upload_id": "upl_01jz2k4v4x9s8m1px6h0y7f2am",
  "store": "default",
  "key": "users/123/uploads/avatar.jpg",
  "filename": "avatar.jpg",
  "content_type": "image/jpeg",
  "bytes": 428129,
  "sha256": "1ecb9f...",
  "metadata": {
    "user_id": "123",
    "purpose": "avatar"
  }
}

Failure event:

{
  "type": "failed",
  "upload_id": "upl_01jz2k4v4x9s8m1px6h0y7f2am",
  "store": "default",
  "key": "users/123/uploads/avatar.jpg",
  "reason": "client_aborted",
  "bytes_received": 1048576,
  "metadata": {
    "user_id": "123",
    "purpose": "avatar"
  }
}

Expected worker response:

{
  "ok": true
}

If the completed handler returns ok: false or times out, the module reports an upload failure to the client and attempts to delete the stored object. Failure event handler errors are logged and counted but do not change an already-failed HTTP response.

Operations

  • Treat returned upload URLs as bearer tokens.
  • Keep token TTLs short.
  • Use HTTPS in production.
  • Store objects outside the public web root unless the application explicitly publishes them.
  • Use shared signing secrets and storage roots when multiple replicas can accept uploads.
  • Do not expose object keys, token values, filenames, or metadata in metrics.

Troubleshooting

  • Invalid token: check signing_secret, expiry, and whether the client is using the returned upload URL unchanged.
  • Content type rejected: make sure the request Content-Type matches the intent's content_types.
  • Upload too large: check both the intent max_bytes and store max_upload_bytes.
  • Unknown progress: progress is process-local and can expire after progress_ttl or disappear after restart.