hostkurd / flocms-uploader
Professional file and image upload package for FloCMS with local, private and S3-ready storage drivers.
Requires
- php: >=8.1
- ext-fileinfo: *
Requires (Dev)
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
- ext-gd: Recommended for image processing support.
- ext-imagick: Recommended for higher-quality image processing support.
- aws/aws-sdk-php: Required only when using the S3 storage driver.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-10 16:58:02 UTC
README
hostkurd/flocms-uploader is a production-ready upload package for FloCMS. It supports:
- local public uploads
- local private uploads
- AWS S3 uploads
- secure MIME and extension validation
- random filenames by default
- optional original-name preservation
- date-based folders
- single and multi-file uploads
- image resizing, fitting, optimization, and version generation
- GD or Imagick image drivers
Installation
composer require hostkurd/flocms-uploader
For AWS S3 support:
composer require aws/aws-sdk-php
Recommended FloCMS structure
public/uploadsfor publicly accessible assets like avatars, post images, and product photos.storage/uploadsfor protected files like invoices, reports, and private user documents.
Configure the package
Create config/upload.php in your FloCMS app and copy the example from this package.
use FloCMS\Uploader\Uploader; Uploader::configure(require ROOT . '/config/upload.php');
A good place to do that is inside your bootstrap sequence, right after your framework loads config.
Example config
return [ 'default_disk' => 'public', 'disks' => [ 'public' => [ 'driver' => 'local', 'root' => ROOT . '/public/uploads', 'url' => '/uploads', 'visibility' => 'public', ], 'private' => [ 'driver' => 'local', 'root' => ROOT . '/storage/uploads', 'visibility' => 'private', ], 's3' => [ 'driver' => 's3', 'key' => getenv('AWS_ACCESS_KEY_ID') ?: '', 'secret' => getenv('AWS_SECRET_ACCESS_KEY') ?: '', 'region' => getenv('AWS_DEFAULT_REGION') ?: 'us-east-1', 'bucket' => getenv('AWS_BUCKET') ?: '', 'url' => getenv('AWS_URL') ?: null, 'prefix' => 'uploads', 'visibility' => 'public', ], ], ];
Basic file upload
use FloCMS\Uploader\Uploader; Uploader::configure(require ROOT . '/config/upload.php'); $result = Uploader::disk('public') ->directory('documents') ->useDatePath() ->allowExtensions(['pdf', 'docx', 'jpg', 'jpeg', 'png']) ->maxBytes(10 * 1024 * 1024) ->upload($_FILES['attachment']); $fileData = $result->toArray();
Directory selection
You can choose the target directory per upload:
Uploader::disk('public')->directory('avatars')->upload($_FILES['avatar']); Uploader::disk('private')->directory('invoices')->upload($_FILES['invoice']);
Multiple uploads
$results = Uploader::disk('public') ->directory('gallery') ->uploadMany($_FILES['images']);
Image uploads with versions
$result = Uploader::image() ->onDisk('public') ->directory('posts') ->useDatePath() ->maxBytes(5 * 1024 * 1024) ->versions([ 'large' => ['resize' => [1600, 1600]], 'medium' => ['resize' => [800, 800]], 'thumb' => ['fit' => [300, 300], 'format' => 'webp', 'quality' => 82], ]) ->upload($_FILES['image']); $thumbUrl = $result->versionUrl('thumb');
The original image is kept by default and stored under:
posts/2026/03/original/abc123.jpg
Generated versions are stored like:
posts/2026/03/large/abc123.jpg
posts/2026/03/medium/abc123.jpg
posts/2026/03/thumb/abc123.webp
AWS S3 example
$result = Uploader::disk('s3') ->directory('products') ->useDatePath() ->allowExtensions(['jpg', 'jpeg', 'png', 'webp']) ->upload($_FILES['image']);
Video uploads (1.2+)
$result = Uploader::video() ->directory('videos') ->withPoster($_FILES['poster'] ?? null) // optional jpg/png/webp ->upload($_FILES['video']); $result->url(); $result->poster()?->url(); // stored as "<video name>-poster.<ext>"
- Accepts mp4 (
video/mp4) and webm (video/webm). The MIME type detected from the content must match the extension. - mov (
video/quicktime, e.g. from iPhones) is off by default. Enable it with'video' => ['allow_mov' => true]in the config or->allowMov(). - Size limit:
video.max_bytes(default 500 MB). Poster limit:video.poster_max_bytes(default 5 MB). - The poster is validated before anything is stored; if storing it fails, the video is removed again.
Chunked, resumable uploads (1.2+)
Shared hosting often limits upload_max_filesize to a few MB. Chunked uploads
send a large file in pieces below that limit and can resume after a network
error. Any uploader can be chunked; the finished file goes through that
uploader's normal validation and storage.
$chunked = Uploader::video()->directory('videos')->chunked(); $session = $chunked->start($filename, $totalBytes, $userId); // checks name and size first $status = $chunked->append($session['upload_id'], $offset, $_FILES['chunk'], $userId); $status = $chunked->status($uploadId, $userId); // resume: continue at received_bytes $result = $chunked->complete($uploadId, $userId); // UploadResult
$offsetmust equal the bytes already received; otherwiseChunkOffsetException::expectedOffset()tells the client where to resume.- A chunk can be a
$_FILESentry or a stream such asfopen('php://input', 'rb'). - Upload ids are random 128-bit values. Pass the logged-in user's id as the owner so only that user can continue or complete the upload.
- Chunks are stored in
chunks.directory(keep it outside the web root) and moved, not copied, into local storage when complete. Uploads without activity forchunks.expireseconds are removed bypurgeExpired(), whichstart()also runs. - Keep
chunks.max_chunk_bytesbelow PHP'supload_max_filesizeandpost_max_size.
See examples/chunked-upload.php (endpoint) and examples/chunked-upload.js
(browser client with retry and resume).
Security notes
- The package validates upload errors before moving files.
- It uses
finfoto detect MIME type. - It validates file extension and MIME type independently.
- It uses random filenames by default.
- It validates real images with
getimagesize()before image processing. - It rejects traversal, absolute paths, unsafe path segments, and symlink escapes from local storage roots.
- It calculates upload size from the temporary file rather than trusting the browser-provided size.
- Common executable/script extensions are blocked by default. Configure
blocked_extensionsonly if a private-storage use case requires a different policy. - A fixed filename cannot replace the extension that was validated.
- It does not support SVG sanitization. Do not allow SVG uploads until you add a sanitizer.
Public API overview
FloCMS\Uploader\Uploader
configure(array $config): voiddisk(string $disk): Uploadermake(?array $config = null): UploaderonDisk(string $disk): selfdirectory(string $directory): selfto(string $directory): selfuseDatePath(bool $enabled = true): selfpreserveOriginalName(bool $enabled = true): selffilename(string $filename): selfvisibility(string $visibility): selfallowExtensions(array $extensions): selfallowMimeTypes(array $mimes): selfmaxBytes(int $bytes): selfimageDimensions(array $dimensions): selfupload(array $file, ?string $directory = null): UploadResultuploadMany(array $files, ?string $directory = null): arrayvideo(?array $config = null): VideoUploader(1.2)chunked(?string $temporaryDirectory = null): ChunkedUploader(1.2)preflight(string $filename, int $bytes): void(1.2)maxUploadBytes(): ?int(1.2)
FloCMS\Uploader\Video\VideoUploader (1.2)
allowMov(bool $enabled = true): selfwithPoster(?array $file): selfposterMaxBytes(int $bytes): self
FloCMS\Uploader\Chunked\ChunkedUploader (1.2)
start(string $filename, int $totalBytes, ?string $owner = null): arrayappend(string $uploadId, int $offset, array|resource $chunk, ?string $owner = null): arraystatus(string $uploadId, ?string $owner = null): arraycomplete(string $uploadId, ?string $owner = null, ?string $directory = null): UploadResultabort(string $uploadId, ?string $owner = null): voidpurgeExpired(): intchunkSize(int $bytes),maxChunkBytes(int $bytes),expireAfter(int $seconds)
FloCMS\Uploader\Image\ImageUploader
versions(array $versions): selfkeepOriginal(bool $enabled = true): selfoptimize(bool $enabled = true): selfquality(int $quality): selfdriver(string $driver): self
FloCMS integration suggestion
Inside your bootstrap after config is loaded:
use FloCMS\Uploader\Uploader; if (is_file(ROOT . '/config/upload.php')) { Uploader::configure(require ROOT . '/config/upload.php'); }
Then your controllers can use the package directly.
Upgrading from 1.1
1.2 is non-breaking. To use the new features, add the video and chunks
sections from config/upload.php to your config (all keys are optional) and
create the chunk directory outside the web root (e.g. storage/uploads/.chunks).
UploadResult has a new poster() method; local storage accepts a move
option used internally for assembled chunked uploads.
Running the tests
composer install vendor/bin/phpunit
Notes
Uploader::disk('public')is the clean entry point for regular files.Uploader::image()->onDisk('public')is the clean entry point for image processing.- If you want a framework-level helper later, you can wrap this package with your own
upload()oruploader()helper in FloCMS core.