phatnt93 / php-upload-helper
A modern, secure and fluent PHP file upload and image manipulation library with first-class multi-file array support, inspired by class.upload.php.
Requires
- php: ^8.1
- ext-fileinfo: *
- ext-mbstring: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
- ext-exif: Required for EXIF auto-orientation of mobile photos
- ext-gd: Required for default image manipulation driver (resize, crop, watermark, convert)
- ext-imagick: Optional high-performance image manipulation driver
Provides
None
Conflicts
None
Replaces
None
README
English | Tiếng Việt
A modern, secure, and fluent PHP file upload and image processing library with first-class multi-file array support, inspired by the legendary verot/class.upload.php.
Table of Contents
- Why Use PHP Upload Helper?
- Key Advantages Over class.upload.php
- System Requirements & Setup
- Installation
- Quick Start
- Real-World Practical Examples
- Comprehensive Action Pipeline & API Reference
- Upload Factory API
- Security & Validation Layer
- File Naming Strategies
- Storage Engine & Collision Handling
- Image Manipulation (Resize Modes, Crop, Rotate, Watermark)
- Format Conversion & Compression
- Color Filters & Visual Effects
- SingleResult & ProcessedFile API
- BatchUploadBuilder & BatchResult API
- Multi-Variant Pipeline (Thumbnails & Previews)
- Lifecycle Hooks & Event Callbacks
- Framework Integration Guides
- Internationalization & Error Codes Catalog
- Security Best Practices & Server Hardening
- Feature Comparison with verot/class.upload.php
- Using with AI Coding Agents
- Callback Signatures & Extension Contracts
- FAQ & Troubleshooting
- Automated Testing & Code Quality
- License & Credits
1. Why Use PHP Upload Helper?
Handling file uploads in native PHP is notorious for friction and security hazards:
- Chaotic Array Transposition: Native
$_FILESsplits multiple files into fragmented sub-arrays ($_FILES['photos']['name'][$i]), forcing developers to write repetitive matrix restructuring loops. - Client Header Trust Vulnerabilities: Checking
$_FILES['file']['type']is insecure because it is an untrusted client header trivially spoofed with cURL or Burp Suite. - Dangerous Extensions & Code Execution: Attackers easily bypass naive blacklists with
.phtml,.php5,.phar, double extensions (shell.php.png), or null-byte injections (shell.php\0.jpg). - Stored XSS in SVGs: Uploaded SVGs containing
<script>or inlineonloadhandlers execute scripts in users' browsers when rendered directly. - Orphan File Pollution: In batch uploads, if 9 out of 10 files succeed and the 10th fails, native PHP leaves orphan files cluttering storage.
- Verbose GD Syntax: Resizing or watermarking images using procedural GD functions requires dozens of boilerplate lines.
php-upload-helper provides an all-in-one, modern, type-safe Fluent Pipeline that encapsulates input normalization, deep binary sniffing, SVG sanitization, image processing, atomic storage, and batch rollback into elegant, chainable PHP 8.1+ syntax.
2. Key Advantages Over class.upload.php
| Feature | Native PHP (move_uploaded_file) |
verot/class.upload.php |
phatnt93/php-upload-helper |
|---|---|---|---|
| Multi-File Array Handling | ❌ None (Requires manual matrix transposition) | ❌ None (Requires manual matrix loops) | 🟢 First-class native support for flat & nested arrays |
| PHP Compatibility | Dependent on server PHP version | Legacy PHP 4/5/7/8 procedural class | 🟢 PHP 8.1 - 8.4+ with strict types and PSR-4 |
| Architecture | Low-level procedural primitives | Monolithic (~6,000 lines single file) | 🟢 Modular Action Pipeline (SRP, clean SOLID design) |
| MIME Validation | Insecure client type header |
Blacklist & basic finfo | 🟢 Strict Whitelist & Magic Bytes Sniffing |
| SVG Sanitization | ❌ None (Stored XSS vulnerability) | ❌ Vulnerable to Stored XSS | 🟢 Built-in XML/SVG script sanitizer |
| Batch Rollback | ❌ Manual file cleanup on error | ❌ None (Leaves orphan files on failure) | 🟢 All-or-nothing rollback on failure (rollbackOnError) |
| Alternative Inputs | Limited to $_FILES |
Limited | 🟢 $_FILES, PSR-7, Base64 URI, Remote URL, Local path |
| Image Drivers | Procedural boilerplate | Hardcoded GD calls | 🟢 Driver Abstraction (GD default, Imagick ready) |
| Server Hardening | ❌ Developer responsibility | Partial (Extension blacklists) | 🟢 Pixel flood, SSRF guard, double-ext, null-byte |
3. System Requirements & Setup
- PHP Compatibility: PHP
>= 8.1(tested on PHP 8.1, 8.2, 8.3, and 8.4 64-bit). - Core Extensions:
ext-fileinfo: Required for magic bytes binary MIME type inspection.ext-mbstring: Required for multibyte string manipulation, Vietnamese slug conversion, and UTF-8 font text rendering.ext-gd: Required for the default image manipulation engine.
- Optional Extensions:
ext-imagick: For advanced image manipulation and vector rendering.ext-exif: For automatic mobile photo orientation based on EXIF camera tags.
4. Installation
Install via Composer into your project:
composer require phatnt93/php-upload-helper
Ensure your php.ini has adequate upload boundaries:
file_uploads = On upload_max_filesize = 50M post_max_size = 60M max_file_uploads = 50 memory_limit = 256M
5. Quick Start
Single File Upload
use UploadHelper\Upload; // Process a single file from $_FILES['avatar'] $result = Upload::file($_FILES['avatar']) ->allowedExtensions(['jpg', 'jpeg', 'png', 'webp']) ->maxSize('5M') ->slugName() ->resize(400, 400, mode: 'crop') ->toWebp(quality: 85) ->saveTo('/var/www/uploads/avatars'); if ($result->isSuccess()) { echo "Saved to: " . $result->getFilePath(); echo "Filename: " . $result->getFilename(); echo "File size: " . $result->getFormattedSize(); } else { echo "Upload failed: " . $result->getErrorMessage(); }
Multi-File Array Upload (First-Class Support)
Say goodbye to manual foreach matrix restructuring for <input type="file" name="photos[]" multiple>!
use UploadHelper\Upload; // Feed the raw $_FILES array directly: $batch = Upload::files($_FILES['photos']) ->allowedExtensions(['jpg', 'png', 'webp']) ->maxSize('10M') ->each(function ($builder, $index) { $builder->randomName(16) ->resize(1200, 800, mode: 'fit') ->watermarkImage('/path/to/watermark.png', position: 'bottom-right', opacity: 70) ->saveTo('/var/www/uploads/gallery'); }) ->rollbackOnError(true) // Automatically deletes saved files if any file in the batch fails ->process(); if ($batch->allSuccessful()) { echo "All " . count($batch->getSuccessful()) . " files uploaded successfully!"; foreach ($batch->getSuccessful() as $file) { echo "- " . $file->getFilename() . " (" . $file->getFormattedSize() . ")\n"; } } else { echo "Batch failed with " . count($batch->getFailed()) . " error(s):\n"; foreach ($batch->getFailed() as $failed) { echo "- " . $failed->getOriginalName() . ": " . $failed->getErrorMessage() . "\n"; } }
Deeply Nested Multi-Dimensional Form Upload
Supports complex form inputs like <input type="file" name="catalog[category][items][0][attachments][]">:
use UploadHelper\Upload; // Automatically flattens and normalizes any nested upload array: $batch = Upload::files($_FILES['catalog']) ->allowedExtensions(['pdf', 'docx', 'jpg', 'png']) ->maxSize('20M') ->saveTo('/var/www/uploads/catalog') ->process(); foreach ($batch->getSuccessful() as $processed) { echo "Field: " . $processed->getFieldKey() . "\n"; echo "Original: " . $processed->getOriginalName() . " -> Saved: " . $processed->getFilename() . "\n"; }
6. Real-World Practical Examples
Example 1: User Profile Avatar with Automatic Crop & WebP
Enforce a clean square avatar, strip harmful metadata, resize to 300x300, and save in modern WebP:
use UploadHelper\Upload; $result = Upload::file($_FILES['avatar']) ->allowedExtensions(['jpg', 'jpeg', 'png', 'webp']) ->allowedMimes(['image/jpeg', 'image/png', 'image/webp']) ->maxSize('2M') ->dimensionBetween(minW: 100, minH: 100, maxW: 4000, maxH: 4000) ->autoOrient() // Fix orientation for mobile camera selfies ->resize(300, 300, mode: 'crop') // Smart center crop ->toWebp(quality: 85) // High-efficiency WebP compression ->name('user_' . $userId . '_avatar') ->overwrite(true) // Update existing avatar ->saveTo('/var/www/app/public/uploads/avatars'); if ($result->isSuccess()) { $avatarUrl = '/uploads/avatars/' . $result->getFilename(); // Update database with $avatarUrl }
Example 2: E-Commerce Product Gallery with Atomic Batch Rollback
Upload product photos in batch, apply watermarks, convert to progressive JPEG, and rollback if any photo is invalid:
use UploadHelper\Upload; $batch = Upload::files($_FILES['product_images']) ->allowedExtensions(['jpg', 'jpeg', 'png']) ->maxSize('8M') ->each(function ($builder, $i) use ($productId) { $builder->name("product_{$productId}_img_" . ($i + 1)) ->resize(1200, 1200, mode: 'fit') ->watermarkImage( watermarkPath: '/assets/img/watermark-logo.png', position: 'bottom-right', opacity: 75, offsetX: 20, offsetY: 20 ) ->toJpeg(quality: 88) ->saveTo("/var/www/app/storage/products/{$productId}"); }) ->rollbackOnError(true) // Crucial: clean disk if any file exceeds size or is corrupt ->process(); if ($batch->allSuccessful()) { foreach ($batch->getSuccessful() as $item) { $db->insertProductImage($productId, $item->getFilename()); } } else { // Zero disk pollution: all previously saved files were deleted automatically! $errors = $batch->getErrors(); $_SESSION['flash_error'] = "Upload failed: " . implode(', ', array_column($errors, 'message')); }
Example 3: Secure Document (PDF/Docx) Storage with Slug Naming
Safely accept resumes or contracts, sanitize filenames (including Vietnamese accents), and auto-increment duplicates:
use UploadHelper\Upload; $result = Upload::file($_FILES['resume']) ->allowedExtensions(['pdf', 'docx', 'doc']) ->allowedMimes([ 'application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/msword', ]) ->maxSize('15M') ->slugName() // "Báo cáo tài chính quý 3.pdf" -> "bao-cao-tai-chinh-quy-3.pdf" ->autoIncrementDuplicate(true) // If exists: "bao-cao-tai-chinh-quy-3_1.pdf" ->saveTo('/var/www/app/storage/documents', chmod: 0750); if ($result->isSuccess()) { echo "Saved safely at: " . $result->getFilePath(); }
Example 4: Sanitized SVG Vector Upload (Stored-XSS Immunity)
SVGs can carry malicious <script> tags. With sanitizeSvg(true), the parser strips all scripts, event handlers (onload, onclick), and javascript: URIs before saving:
use UploadHelper\Upload; $result = Upload::file($_FILES['company_logo']) ->allowedExtensions(['svg']) ->allowedMimes(['image/svg+xml']) ->maxSize('1M') ->sanitizeSvg(true) // Strips <script>, <foreignObject>, onload, onerror, javascript: ->uuidName() // Generates UUID v4 name ->saveTo('/var/www/app/public/logos'); if ($result->isSuccess()) { echo "Clean, XSS-free SVG saved: " . $result->getFilename(); }
Example 5: REST API / SPA Upload via Base64 or PSR-7
Accept image uploads from mobile apps, Vue/React Canvas, or PSR-7 HTTP requests:
use UploadHelper\Upload; // 1. Mobile / Web Canvas Base64 Data URI: $base64String = $requestPayload['avatar_base64']; // "data:image/png;base64,iVBORw0KGgo..." $result = Upload::fromBase64($base64String, filename: 'canvas_drawing.png') ->maxSize('3M') ->resize(500, 500, mode: 'fit') ->toWebp(quality: 80) ->saveTo('/uploads/drawings'); // 2. PSR-7 UploadedFile (Slim, Laminas, Nyholm, Guzzle): $psr7File = $request->getUploadedFiles()['profile_picture']; $result = Upload::fromPsr7($psr7File) ->allowedExtensions(['jpg', 'png']) ->saveTo('/uploads/profiles'); // 3. Download from Remote URL: $result = Upload::fromUrl('https://images.unsplash.com/photo-1579783900882-c0d3dad7b119') ->resize(800, 600) ->saveTo('/uploads/unsplash');
7. Comprehensive Action Pipeline & API Reference
Upload Factory API
Entry points from the UploadHelper\Upload facade:
// Standard Web Form Input $builder = Upload::file($_FILES['avatar']); // Single $_FILES element $batch = Upload::files($_FILES['photos']); // Multi-file $_FILES element $batch = Upload::fromGlobals(); // All $_FILES at once // Alternative Sources $builder = Upload::fromPath('/path/to/local/file.jpg'); $builder = Upload::fromBase64('data:image/png;base64,iVBORw0KGgo...', 'avatar.png'); $builder = Upload::fromUrl('https://example.com/asset.png', timeout: 15, maxDownloadBytes: 52_428_800); $builder = Upload::fromPsr7($psr7UploadedFile);
Security & Validation Layer
All validation methods are available on UploadBuilder and BatchUploadBuilder:
| Method | Description | Example |
|---|---|---|
allowedExtensions(array $exts) |
Strict whitelist of allowed extensions (lowercase). | ->allowedExtensions(['jpg', 'png', 'pdf']) |
allowedMimes(array $mimes) |
Whitelist of MIME types validated via magic bytes. | ->allowedMimes(['image/jpeg', 'image/png']) |
maxSize(int|string $maxSize) |
Maximum file size in bytes or human string (K, M, G). |
->maxSize('10M') or ->maxSize(10485760) |
minSize(int|string $minSize) |
Minimum file size in bytes or human string. | ->minSize('5K') |
dimensionBetween(?int $minW, ?int $minH, ?int $maxW, ?int $maxH) |
Enforce bounding box constraints on images. | ->dimensionBetween(minW: 200, maxW: 1920) |
sanitizeSvg(bool $sanitize = true) |
Enable deep DOM XML sanitization to prevent Stored XSS. | ->sanitizeSvg(true) |
maxPixels(int $maxPixels) |
Prevent Decompression Bomb DoS by limiting max megapixels. | ->maxPixels(40_000_000) |
maxImageMemory(int|string $maxMemory) |
Limit estimated uncompressed RGBA RAM before GD loading. | ->maxImageMemory('128M') |
blockPdfJavaScript(bool $block = true) |
Block active JavaScript, /Launch, and /EmbeddedFiles in PDF. | ->blockPdfJavaScript(true) |
blockOfficeMacros(bool $block = true) |
Block VBA Macros (vbaProject.bin) in Office documents. | ->blockOfficeMacros(true) |
scanner(ScannerInterface $scanner) |
Real-time Antivirus scan via ClamAV daemon socket. | ->scanner(new ClamAvScanner()) |
File Naming Strategies
| Method | Behavior | Example Output |
|---|---|---|
slugName() |
Converts original name to safe ASCII slug (supports Vietnamese). | Ảnh_đẹp 2026.png -> anh-dep-2026.png |
uuidName() |
Generates standard UUID v4. | 550e8400-e29b-41d4-a716-446655440000.png |
randomName(int $len = 16) |
Cryptographically secure hex string. | a9b3f021e78c45d2.png |
timestampName() |
Date and microsecond timestamp. | 20261015_153045_8912.png |
hashName(string $algo = 'sha256', int $len = 64) |
Content-addressable hash name of file body. | e3b0c44298fc1c14...png |
sha256Name(int $len = 64) |
Shortcut for SHA-256 hash naming. | a8b3c4...png |
name(string|Closure $name) |
Set fixed name or compute dynamic name via Closure. | ->name('cover_image') |
prefix(string $prefix) |
Prepends prefix to filename. | ->prefix('thumb_') |
suffix(string $suffix) |
Appends suffix before extension. | ->suffix('_optimized') |
Storage Engine & Collision Handling
| Method | Description |
|---|---|
saveTo(string $destinationDir, int $chmod = 0755) |
Sets target directory, creates recursively with $chmod, and executes save. |
overwrite(bool $overwrite = true) |
Replaces destination file if it already exists. |
autoIncrementDuplicate(bool $auto = true) |
Automatically appends sequential suffix (_1, _2) if destination exists. |
hardenDestination(bool $harden = true) |
Automatically generates .htaccess, web.config, and index.html to block script execution. |
Image Manipulation (Resize Modes, Crop, Rotate, Watermark)
1. Resize Modes
$builder->resize(800, 600, mode: 'fit', background: '#FFFFFF');
fit(Default): Proportional scaling so the whole image fits inside$width×$height. Never distorts aspect ratio.crop: Scales and centers the image to completely fill the$width×$heightbox, cropping excess parts.pad: Fits the image proportionally inside the box and fills the outer space with$background(e.g.'#FFFFFF').stretch: Distorts the image to match the exact dimensions.withoutEnlarging(true): Prevents upscaling if original image is smaller than target dimensions.
2. Cropping, Rotating & Orientation
$builder->crop(400, 300, x: 10, y: 10); // Freeform rectangle $builder->rotate(90.0, background: '#000000'); // Custom angle rotation $builder->flipHorizontal(); // Horizontal mirror $builder->flipVertical(); // Vertical mirror $builder->autoOrient(); // Read EXIF camera orientation $builder->stripExif(keepOrientation: true); // Strip GPS and camera metadata for user privacy
3. Watermarking
// Image Overlay: $builder->watermarkImage( watermarkPath: '/path/to/watermark.png', position: 'bottom-right', // 'top-left', 'top-right', 'bottom-left', 'bottom-right', 'center' opacity: 80, // 0 to 100% offsetX: 15, offsetY: 15, ); // TrueType Text Watermark: $builder->watermarkText( text: '© 2026 MyBrand', fontPath: '/path/to/font.ttf', // Path to .ttf font file (uses built-in font if omitted) size: 16, color: '#FFFFFF', // Hex color position: 'bottom-right', background: '#000000', // Optional background padding block backgroundOpacity: 50, offsetX: 10, offsetY: 10, );
Format Conversion & Compression
| Method | Output Format | Key Options |
|---|---|---|
toWebp(int $quality = 85) |
WebP | $quality (1 - 100). Highly recommended for modern web. |
toJpeg(int $quality = 85) |
JPEG | $quality (1 - 100). Automatically saves in progressive format. |
toPng() |
PNG | Lossless compression with alpha channel preservation. |
toAvif(int $quality = 80) |
AVIF | Next-gen image format (requires PHP 8.1+ with AVIF GD support). |
Color Filters & Visual Effects
$builder->grayscale(); // Convert to black and white $builder->brightness(20); // Brightness: -255 to +255 $builder->contrast(-10); // Contrast: -100 to +100 $builder->blur(); // Gaussian blur $builder->sharpen(); // Unsharp mask filter $builder->invert(); // Invert image colors
SingleResult & ProcessedFile API
Calling ->saveTo() or ->process() on UploadBuilder returns a SingleResult:
$result = Upload::file($_FILES['photo'])->saveTo('/uploads'); if ($result->isSuccess()) { $file = $result->getFile(); // Instance of ProcessedFile echo $file->getOriginalName(); // e.g. "My Picture.png" echo $file->getFilename(); // e.g. "my-picture.webp" echo $file->getSavedPath(); // e.g. "/uploads/my-picture.webp" echo $file->getFilePath(); // Alias to getSavedPath() echo $file->getMime(); // e.g. "image/webp" echo $file->getExtension(); // e.g. "webp" echo $file->getSize(); // Size in bytes echo $file->getFormattedSize(); // e.g. "1.42 MB" echo $file->getWidth(); // e.g. 1200 echo $file->getHeight(); // e.g. 800 print_r($file->getDimensions()); // ['width' => 1200, 'height' => 800] print_r($file->toArray()); // Export array } else { echo $result->getErrorMessage(); // Translated error message echo $result->getErrorCode(); // e.g. "ERR_FILE_TOO_LARGE" print_r($result->getErrors()); // ['code' => ..., 'message' => ..., 'context' => ...] // Technical detail for logs only (English, may contain paths) - never show it to end users: error_log($result->getErrors()['context']['reason'] ?? ''); }
BatchUploadBuilder & BatchResult API
Calling ->process() on BatchUploadBuilder returns a BatchResult:
$batch = Upload::files($_FILES['photos']) ->saveTo('/uploads/gallery') ->rollbackOnError(true) ->process(); $batch->allSuccessful(); // Returns true if all files succeeded $batch->hasFailures(); // Returns true if one or more files failed $batch->count(); // Total processed files count // Access lists: $batch->getSuccessful(); // Array of ProcessedFile objects $batch->getFailed(); // Array of SingleResult error objects $batch->getErrors(); // Array of error details keyed by original name
Multi-Variant Pipeline (Thumbnails & Previews)
Generate multiple derivative sizes, formats, or watermarked variants from a single uploaded file without re-uploading:
use UploadHelper\Upload; $result = Upload::file($_FILES['avatar']) ->allowedExtensions(['jpg', 'jpeg', 'png', 'webp']) ->slugName() ->destination('/var/www/uploads/avatars/original') // Main/Original file destination // Variant 1: Square thumbnail for profile lists ->variant('thumb', function ($v) { $v->resize(150, 150, mode: 'crop') ->toWebp(quality: 80) ->saveTo('/var/www/uploads/avatars/thumbs'); }) // Variant 2: Mid-size preview for profile header ->variant('preview', function ($v) { $v->resize(600, 400, mode: 'fit') ->toWebp(quality: 85) ->saveTo('/var/www/uploads/avatars/previews'); }) ->process(); if ($result->isSuccess()) { echo "Original: " . $result->getFilePath() . "\n"; // Access variants easily: $thumb = $result->getVariant('thumb'); echo "Thumbnail: " . $thumb->getSavedPath() . " (" . $thumb->getWidth() . "x" . $thumb->getHeight() . ")\n"; $preview = $result->getVariant('preview'); echo "Preview: " . $preview->getSavedPath() . "\n"; // Access all variants: foreach ($result->getVariants() as $name => $variantFile) { echo "- Variant '{$name}': " . $variantFile->getFilename() . "\n"; } }
Lifecycle Hooks & Event Callbacks
Attach hooks to intercept and react during the upload lifecycle:
Upload::file($_FILES['contract']) ->beforeProcess(function (UploadedFile $uploadedFile): void { // Runs before validation & processing Log::info("Starting upload for: " . $uploadedFile->getOriginalName()); }) ->onSuccess(function (ProcessedFile $processedFile, SingleResult $result): void { // Runs only when upload succeeded Database::recordUpload($processedFile->getFilename(), $processedFile->getSize()); Notification::send("Upload complete!"); }) ->onError(function (string $errorMessage, string $errorCode, SingleResult $result): void { // Runs when validation or processing failed Log::warning("Upload failed [{$errorCode}]: {$errorMessage}"); }) ->afterProcess(function (SingleResult $result): void { // Always runs after process finishes (success or failure) Metrics::increment('uploads.attempted'); }) ->saveTo('/uploads/contracts');
8. Framework Integration Guides
Vanilla PHP Web Form
<!-- index.html --> <form action="upload.php" method="POST" enctype="multipart/form-data"> <label>Select Photos:</label> <input type="file" name="photos[]" multiple required> <button type="submit">Upload Gallery</button> </form>
// upload.php require_once __DIR__ . '/vendor/autoload.php'; use UploadHelper\Upload; $batch = Upload::files($_FILES['photos']) ->allowedExtensions(['jpg', 'png', 'webp']) ->maxSize('5M') ->slugName() ->resize(1200, 800) ->toWebp(85) ->saveTo(__DIR__ . '/public/uploads') ->rollbackOnError(true) ->process(); if ($batch->allSuccessful()) { header('Location: /gallery.php?success=1'); } else { echo "Errors: " . json_encode($batch->getErrors()); }
Laravel Integration
In your Laravel Controller:
namespace App\Http\Controllers; use Illuminate\Http\Request; use UploadHelper\Upload; class MediaController extends Controller { public function uploadAvatar(Request $request) { $request->validate(['avatar' => 'required|file']); // Feed Symfony/Laravel UploadedFile directly via path: $file = $request->file('avatar'); $result = Upload::fromPath($file->getRealPath(), $file->getClientOriginalName()) ->allowedExtensions(['jpg', 'jpeg', 'png', 'webp']) ->maxSize('5M') ->resize(400, 400, mode: 'crop') ->toWebp() ->saveTo(public_path('uploads/avatars')); if (!$result->isSuccess()) { return back()->withErrors(['avatar' => $result->getErrorMessage()]); } auth()->user()->update(['avatar' => $result->getFilename()]); return back()->with('status', 'Avatar updated!'); } }
Symfony Integration
In your Symfony Controller:
namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use UploadHelper\Upload; class UploadController extends AbstractController { public function handleUpload(Request $request): Response { /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $uploadedFile */ $uploadedFile = $request->files->get('document'); $result = Upload::fromPath($uploadedFile->getRealPath(), $uploadedFile->getClientOriginalName()) ->allowedExtensions(['pdf', 'docx']) ->maxSize('10M') ->slugName() ->saveTo($this->getParameter('kernel.project_dir') . '/var/uploads'); if ($result->isSuccess()) { return $this->json(['status' => 'success', 'filename' => $result->getFilename()]); } return $this->json(['error' => $result->getErrorMessage()], 422); } }
Phalcon Integration
In your Phalcon Controller:
namespace App\Controllers; use Phalcon\Mvc\Controller; use UploadHelper\Upload; class FileController extends Controller { public function uploadAction() { if ($this->request->hasFiles()) { $batch = Upload::fromGlobals($_FILES) ->allowedExtensions(['jpg', 'png', 'webp']) ->maxSize('8M') ->saveTo($this->config->application->uploadDir) ->process(); if ($batch->allSuccessful()) { $this->flash->success("Uploaded " . count($batch->getSuccessful()) . " files!"); } else { $this->flash->error("Failed to upload some files."); } } } }
Slim / PSR-7 Frameworks
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use UploadHelper\Upload; $app->post('/api/upload', function (Request $request, Response $response) { $uploadedFiles = $request->getUploadedFiles(); $avatar = $uploadedFiles['avatar'] ?? null; if (!$avatar) { $response->getBody()->write(json_encode(['error' => 'No file uploaded'])); return $response->withStatus(400)->withHeader('Content-Type', 'application/json'); } $result = Upload::fromPsr7($avatar) ->allowedExtensions(['jpg', 'png', 'webp']) ->maxSize('2M') ->resize(300, 300, mode: 'crop') ->toWebp() ->saveTo(__DIR__ . '/public/uploads'); $response->getBody()->write(json_encode([ 'success' => $result->isSuccess(), 'filename' => $result->getFilename(), 'error' => $result->getErrorMessage(), ])); return $response->withHeader('Content-Type', 'application/json'); });
9. Internationalization & Error Codes Catalog
Setting the Active Locale
The default locale is English (en); Vietnamese (vi) ships with the library. Missing keys fall back to the active locale → en → the error code itself.
use UploadHelper\I18n\Translator; // Switch the locale globally (format: "xx", "xxx" or "xx_YY"; anything else throws InvalidLocaleException): Translator::setLocale('vi'); // Register or override translations for any locale: Translator::addTranslations('es', [ 'ERR_FILE_TOO_LARGE' => 'El archivo ({formatted_actual}) supera el máximo permitido ({formatted_max}).', 'ERR_EXTENSION_NOT_ALLOWED' => 'La extensión .{extension} no está permitida.', ]); Translator::setLocale('es'); Translator::has('ERR_FILE_TOO_LARGE', 'es'); // true
Messages returned by SingleResult::getErrorMessage() are translated with the active locale. Exceptions thrown inside the library always carry an English message; it is kept in getErrors()['context']['reason'] for logging. Unexpected internal errors are reported as ERR_INTERNAL with a generic message so that paths and internals never leak to end users.
Standardized Error Codes
The full message catalog lives in src/I18n/en.php and src/I18n/vi.php; both always contain the same keys (enforced by tests). Placeholders such as {extension} are filled from the error context.
| Group | Error codes |
|---|---|
| PHP upload errors | ERR_UPLOAD_INI_SIZE, ERR_UPLOAD_FORM_SIZE, ERR_UPLOAD_PARTIAL, ERR_UPLOAD_NO_FILE, ERR_UPLOAD_NO_TMP_DIR, ERR_UPLOAD_CANT_WRITE, ERR_UPLOAD_EXTENSION, ERR_UPLOAD_UNKNOWN, ERR_NO_FILE |
| Extension & MIME | ERR_EXTENSION_NOT_ALLOWED, ERR_MIME_NOT_ALLOWED, ERR_SECURITY_DANGEROUS_EXTENSION, ERR_SECURITY_DOUBLE_EXTENSION, ERR_SECURITY_NULL_BYTE, ERR_PATH_TRAVERSAL |
| Size & dimensions | ERR_FILE_TOO_LARGE, ERR_FILE_TOO_SMALL, ERR_NOT_AN_IMAGE, ERR_DIMENSION_MIN_WIDTH, ERR_DIMENSION_MAX_WIDTH, ERR_DIMENSION_MIN_HEIGHT, ERR_DIMENSION_MAX_HEIGHT, ERR_DIMENSION_EXACT_WIDTH, ERR_DIMENSION_EXACT_HEIGHT, ERR_DIMENSION_RATIO, ERR_IMAGE_PIXEL_FLOOD |
| Content security | ERR_INVALID_SVG_XML, ERR_PDF_JAVASCRIPT_DETECTED, ERR_PDF_LAUNCH_DETECTED, ERR_PDF_EMBEDDED_FILES_DETECTED, ERR_OFFICE_MACRO_DETECTED, ERR_VIRUS_DETECTED, ERR_SCANNER_ERROR |
| Remote URL (SSRF) | ERR_SSRF_INVALID_URL, ERR_SSRF_DISALLOWED_SCHEME, ERR_SSRF_DISALLOWED_PORT, ERR_SSRF_DNS_RESOLVE_FAILED, ERR_SSRF_BLOCKED, ERR_URL_DOWNLOAD_FAILED, ERR_URL_DOWNLOAD_TOO_LARGE, ERR_URL_TOO_MANY_REDIRECTS |
| Storage & processing | ERR_FILE_EXISTS, ERR_VARIANT_FAILED, ERR_PROCESS_FAILED, ERR_VALIDATION, ERR_INTERNAL |
10. Security Best Practices & Server Hardening
-
Store Uploads Outside the Web Root: Always store private files outside the public document root (e.g.,
/storage/uploadsinstead of/public/uploads) and serve them via a secure streaming controller with access authorization. -
Disable Script Execution in Upload Folders: If serving directly from Nginx, disable PHP execution in the public upload directory:
# Nginx server block location ^~ /uploads/ { location ~ \.(php|phtml|phar|sh|pl|py|cgi)$ { deny all; return 403; } }
For Apache, place an
.htaccessfile inside your upload directory:# Prevent executing any scripts in the uploads folder php_flag engine off <FilesMatch "\.(php|phtml|phar|sh|pl|cgi)$"> Order Deny,Allow Deny from all </FilesMatch>
-
Always Whitelist Extensions: Never use blacklists. Attackers continuously discover new executable suffixes (e.g.,
.php7,.pht,.shtml,.inc). Always pass explicit whitelists via->allowedExtensions(['jpg', 'png', 'pdf']).
11. Feature Comparison with verot/class.upload.php
verot/class.upload.php Property |
UploadHelper Fluent Action |
|---|---|
$foo->file_safe_name = true; |
->slugName() |
$foo->file_new_name_body = 'id'; |
->name('id') |
$foo->file_name_body_pre = 'pre_'; |
->prefix('pre_') |
$foo->file_name_body_add = '_post'; |
->suffix('_post') |
$foo->file_auto_rename = true; |
->autoIncrementDuplicate() |
$foo->file_overwrite = true; |
->overwrite(true) |
$foo->file_max_size = 5242880; |
->maxSize('5M') |
$foo->allowed = ['image/*']; |
->allowedMimes(['image/jpeg', 'image/png']) |
$foo->image_resize = true; |
->resize($w, $h, mode: 'fit') |
$foo->image_ratio_crop = true; |
->resize($w, $h, mode: 'crop') |
$foo->image_ratio_fill = true; |
->resize($w, $h, mode: 'pad') |
$foo->image_ratio_no_zoom_in = true; |
->withoutEnlarging() |
$foo->image_watermark = 'wm.png'; |
->watermarkImage('/path/wm.png') |
$foo->image_text = 'Copyright'; |
->watermarkText('Copyright') |
$foo->image_convert = 'webp'; |
->toWebp() |
$foo->image_greyscale = true; |
->grayscale() |
$foo->image_brightness = 20; |
->brightness(20) |
$foo->image_contrast = -10; |
->contrast(-10) |
$foo->dir_auto_create = true; |
Handled automatically in ->saveTo() |
12. Using with AI Coding Agents
If you use AI coding assistants (Claude Code, GitHub Copilot, Cursor, ChatGPT, Codex) in your project, you can provide them with machine-optimized context files:
- Quick LLM Guide:
vendor/phatnt93/php-upload-helper/llms.txt(API summary and common copy-paste recipes) - Deep Technical Reference:
vendor/phatnt93/php-upload-helper/llms-full.txt(complete class signatures, 100% error catalog, extensibility interfaces) - Standalone Examples:
examples/(executable reference scripts covering single file, batch upload, variants, and security)
Prompt Example for Your Project Rules
Add this line to your project's .cursorrules, CLAUDE.md, or AGENTS.md:
"When implementing file uploads or image processing in PHP, use
phatnt93/php-upload-helper. Refer tovendor/phatnt93/php-upload-helper/llms.txtfor fluent API usage and validation recipes."
13. Callback Signatures & Extension Contracts
All callback signatures in php-upload-helper are strictly typed and committed to backwards-compatibility within major versions:
| Method / Hook | Callback Signature | Timing / Purpose |
|---|---|---|
variant(string $name, callable $configurator) |
callable(UploadBuilder $builder): void |
Configures a derivative image variant pipeline |
each(callable $callback) (Batch) |
callable(UploadBuilder $builder, int $index): void |
Configures per-file rules in a batch |
beforeProcess(callable $callback) (Single) |
callable(UploadedFile $file): void |
Executed prior to validation and pipeline actions |
onSuccess(callable $callback) (Single) |
callable(ProcessedFile $file, SingleResult $result): void |
Executed only when file is successfully saved |
onError(callable $callback) (Single) |
callable(string $errorMessage, string $errorCode, SingleResult $result): void |
Executed when validation or processing fails |
afterProcess(callable $callback) (Single) |
callable(SingleResult $result): void |
Executed after pipeline finishes (success or failure) |
beforeProcess(callable $callback) (Batch) |
callable(FileCollection $collection): void |
Executed prior to processing batch |
onSuccess(callable $callback) (Batch) |
callable(BatchResult $result): void |
Executed when all files in batch succeed |
onError(callable $callback) (Batch) |
callable(BatchResult $result): void |
Executed when one or more files in batch fail |
afterProcess(callable $callback) (Batch) |
callable(BatchResult $result): void |
Executed after batch finishes (success or failure) |
name(Closure $custom) |
Closure(UploadedFile $file): string |
Custom dynamic filename generator |
Custom Extension Interfaces
To extend the library with custom logic:
- Custom Actions: Implement
UploadHelper\Contracts\ActionInterface(getName(): string,execute(UploadedFile $file, ActionContext $context): UploadedFile). - Custom Malware Scanners: Implement
UploadHelper\Contracts\ScannerInterface(scan(string $filePath): bool,getThreatName(): ?string). - Custom Storage Backends: Implement
UploadHelper\Contracts\StorageInterface(save(...),delete(...),exists(...)). - Custom Image Drivers: Implement
UploadHelper\Contracts\ImageDriverInterface.
14. FAQ & Troubleshooting
Q: Why do I get an empty $_FILES or immediate UPLOAD_ERR_INI_SIZE?
A: The file exceeds either upload_max_filesize or post_max_size in your active php.ini. Note that post_max_size must always be larger than or equal to upload_max_filesize. For multi-file uploads, post_max_size must be large enough to contain the sum of all files in the batch request.
Q: Why does toWebp() or toAvif() fail on my server?
A: Check if your PHP GD build was compiled with WebP or AVIF support:
php -r "var_dump(function_exists('imagewebp'));" php -r "var_dump(function_exists('imageavif'));"
If false, install libwebp-dev or libavif-dev and recompile/install the PHP GD extension.
Q: How do I create both a large preview and a small thumbnail from the same uploaded file?
A: Use the built-in multi-variant pipeline:
$result = Upload::file($_FILES['photo']) ->allowedExtensions(['jpg', 'png', 'webp']) ->resize(800, 600, mode: 'contain') ->prefix('preview_') ->variant('thumb', function ($v): void { $v->resize(150, 150, mode: 'cover') ->prefix('thumb_') ->toWebp(quality: 80); }) ->saveTo('/uploads/gallery');
15. Automated Testing & Code Quality
Run the automated quality and test suites:
# Run PHPUnit tests composer test # Run PHPStan static analysis (Level 8) composer analyse # Check code formatting (PER-CS 2.0) composer cs:check # Measure test line coverage (enforces >= 90%) composer test:coverage # Run standalone examples for f in examples/*.php; do php "$f"; done
16. License & Credits
- License: Licensed under the MIT License.
- Author: Phat Nguyen (
phatnt93@gmail.com). - Inspiration: Special thanks to Colin Verot for the historical verot/class.upload.php.