(Without Fighting upload_max_filesize)
You deploy your app. A user picks a 400 MB video. They hit upload. The progress bar freezes. Then — nothing.
You check the logs. POST Content-Length exceeded post_max_size. Again.
We've all been there. The fix is usually "raise PHP limits" or "use S3." Both work — until you're on shared hosting, a legacy VPS, or a client who won't touch php.ini.
That's the problem Pinion solves.
What is Pinion?
Pinion is an open-source resumable chunked upload protocol for PHP.
Instead of one giant multipart/form-data request, the browser sends the file in small parts (default: 5 MB). The server stores each part, then assembles the final file on disk.
Three steps. That's the whole contract:
init → upload parts → complete
| Package | Registry | Role |
|---|---|---|
pinoox/pinion |
Packagist | PHP server engine |
@pinooxhq/pinion-client |
npm | Browser client |
Protocol id: pinion · version: 2
Why not just use S3?
Object storage is great. But sometimes you need files on your server:
- A CMS media library on local disk
- A Laravel app without cloud budget
- Shared hosting with no S3 SDK
- An admin panel behind a simple PHP API
Pinion isn't a CDN or a storage service. It's a protocol — a stable HTTP contract that works in plain PHP, Laravel, or Pinoox.
How it works (30-second version)
sequenceDiagram
participant Browser
participant API
participant Disk
Browser->>API: POST /init (filename, size, fingerprint)
API-->>Browser: upload_id, chunk_size, missing_indexes
loop Each part
Browser->>API: POST /upload (chunk + SHA-256 hash)
API->>Disk: store part
end
Browser->>API: POST /complete
API->>Disk: assemble file
API-->>Browser: done ✓
Resume is built in. The client sends a fingerprint (name:size:lastModified:type). If the connection drops, the same file picks up where it left off — only missing parts are re-uploaded.
Integrity too. Each part gets a SHA-256 chunk_hash. The server can reject corrupted chunks before they pollute your disk.
Server side: 10 lines of PHP
composer require pinoox/pinion
use Pinoox\Pinion\Pinion;
Pinion::configure(['storage_path' => '/tmp/pinion']);
$handler = Pinion::http(['destination' => 'uploads/videos']);
$handler->init($_POST);
$handler->upload($_POST, $_FILES['chunk'] ?? null);
$handler->complete($_POST);
Wire five routes under any prefix you like:
POST /api/v1/upload/init
POST /api/v1/upload/upload
POST /api/v1/upload/complete
GET /api/v1/upload/status/{id}
POST /api/v1/upload/abort/{id}
HttpHandler returns plain arrays — map them to JSON in Laravel, Pinoox, or raw PHP. No framework lock-in.
Browser side: one function, zero extra deps
npm install @pinooxhq/pinion-client
import { uploadFile } from '@pinooxhq/pinion-client';
await uploadFile(file, {
baseURL: '/api/v1/upload',
unwrapPreset: 'pinoox',
onProgress: ({ percent, speed, eta }) => {
console.log(`${percent}% · ${speed} B/s · ETA ${eta}s`);
},
});
No Axios required. The client uses native fetch by default. Already on Axios? Pass it in — you get per-chunk onUploadProgress too.
baseURL is just the prefix. The client calls /init, /upload, /complete for you. You don't loop over chunks manually unless you want to.
Level up when you need to
Start simple. Grow when the project demands it.
| Need | API |
|---|---|
| One upload button | uploadFile(file, options) |
| Reusable uploader | pinion({ baseURL }).for(file).upload() |
| Batch + cancel + hooks | createPinionFetch(options) |
| Full manual control |
client.api.init() → uploadPart() → complete()
|
Small files? Skip Pinion entirely:
const result = await uploadFile(file, {
baseURL: '/api/v1/upload',
auto: true,
threshold: 8 * 1024 * 1024,
});
if (result === null) {
// file under 8 MB — use your normal single POST
}
What I like about the design
1. Boring HTTP. JSON for init/complete, FormData for chunks. No WebSockets, no custom binary framing. Debug with curl or DevTools.
2. Parallel by default. Upload 2 parts at once. Retry failed parts with backoff. Progress includes speed and ETA — not just a percentage.
3. Framework adapters, not framework prison. Core engine is pure PHP. Laravel gets a Service Provider and Facade. Pinoox gets a Portal and CLI (pinion:list, pinion:clean). Plain PHP gets HttpHandler.
4. Unwrap presets. Your API returns { data: { … } }? Set unwrapPreset: 'pinoox'. Flat JSON? Use 'flat'. The client adapts; you don't rewrite parsers.
Real-world fit
| Scenario | Pinion helps because… |
|---|---|
| Shared hosting (20 MB cap) | 5 MB parts fit under the limit |
| Mobile / flaky Wi-Fi | Resume after disconnect |
| Admin upload panels | Progress bar with real bytes + ETA |
| Video courses / archives | GB-scale without touching php.ini
|
| Multi-framework teams | Same protocol, PHP + JS packages |
Try it
# Server
composer require pinoox/pinion
# Browser
npm install @pinooxhq/pinion-client
- Repo: github.com/pinoox/pinion
- PHP docs: README
- Client docs: client/README
- License: MIT
If you've fought upload_max_filesize one too many times, Pinion might save your next Friday night.
Questions, issues, or war stories welcome in the repo. 🙌