devwizardhq/laravel-filex
Laravel Filex is a Dropzone.js-powered Blade component for Laravel with drag-and-drop uploads, chunked large-file transfers, real-time progress, multi-layer validation, quarantine security, temp storage, and auto cleanup.
Install and publish
composer require devwizardhq/laravel-filex
php artisan filex:install # config + assets
php artisan filex:info # confirms routes/config loaded
Then add @filexAssets once in your layout <head> (or before </body>):
{{-- resources/views/layouts/app.blade.php --}}
@filexAssets
That directive is what registers the Dropzone JS/CSS and the package's upload routes. Without it the component renders but uploads fail silently.
Where to look first
config/filex.php — storage disks (default, temp), max_file_size, temp_expiry_hours, routes.prefix, security.*.HasFilex trait — this is the API you'll actually call 90% of the time.DevWizard\Filex\Rules\ValidFileUpload — the validation presets you must use on temp paths.php artisan route:list --path=filex — confirms the AJAX endpoints exist.First use case: avatar upload
Blade:
<form method="POST" action="{{ route('profile.update') }}">
@csrf
<x-filex-uploader
name="avatar"
label="Avatar"
:max-size="5"
mimes="jpeg,png,webp"
dimensions="min_width=200,min_height=200"
/>
@error('avatar') <span class="text-red-500">{{ $message }}</span> @enderror
<button type="submit">Save</button>
</form>
Controller:
use DevWizard\Filex\Traits\HasFilex;
use DevWizard\Filex\Rules\ValidFileUpload;
class ProfileController extends Controller
{
use HasFilex;
public function update(Request $request)
{
$request->validate([
'avatar' => ['nullable', ValidFileUpload::forImages(maxSizeMB: 5)],
]);
$data = $request->only('name');
if ($request->filled('avatar') && str_starts_with($request->avatar, 'temp/')) {
$data['avatar'] = $this->moveFilePublic($request, 'avatar', 'avatars');
}
auth()->user()->update($data);
return back()->with('success', 'Profile updated.');
}
}
The mental model to internalise immediately: the form field contains a string path (temp/abc123_photo.jpg), not an UploadedFile. Everything else follows from that.
Single vs. multiple fields
<x-filex-uploader name="documents" :multiple="true" :max-files="10" :max-size="10" mimes="pdf,doc,docx" />
Validate both the array and each element — Laravel will not apply the rule to items for you:
$request->validate([
'documents' => ['required', 'array', 'max:10'],
'documents.*' => [ValidFileUpload::forDocuments(10)],
]);
$paths = $this->moveFilesPrivate($request, 'documents', 'post-documents');
Create-and-store pattern with mixed image + attachments
$imagePath = $this->moveFilePublic($request, 'featured_image', 'posts/images');
$attachPaths = $this->moveFilesPrivate($request, 'attachments', 'posts/attachments');
$post = Post::create([
'title' => $validated['title'],
'featured_image' => $imagePath, // permanent path only
'attachments' => $attachPaths,
]);
Edit forms — the :value prop and the temp/ guard
Pre-populate with existing files so users see what's already attached:
<x-filex-uploader name="featured_image" :value="$post->featured_image" mimes="jpeg,png,webp" :max-size="5" />
<x-filex-uploader name="attachments" :multiple="true" :value="$post->attachments" mimes="pdf,docx" />
On update, only move when the submitted value is actually a new temp file. Existing permanent paths will be re-posted unchanged:
if ($request->filled('featured_image') && str_starts_with($request->featured_image, 'temp/')) {
$data['featured_image'] = $this->moveFilePublic($request, 'featured_image', 'posts/images');
}
// a bare `$request->featured_image` starting with 'posts/' is already permanent — leave it alone
Make the rule nullable here, not required — the field is legitimately empty when unchanged.
Outside controllers: the facade
Use Filex in jobs, imports, console commands, and service classes:
use DevWizard\Filex\Facades\Filex;
$result = Filex::moveFiles($tempPaths, 'imports/documents', 's3', 'private');
if (! $result->isAllSuccess()) {
Log::warning('Filex move failures', ['errors' => $result->getErrorMessages()]);
}
$paths = $result->getPaths(); // successful only
Check the result object every time — the facade does not throw on partial failure.
Disk and visibility selection
$this->moveFile($request, 'avatar', 'avatars'); // default disk + visibility
$this->moveFile($request, 'contract', 'contracts', 's3'); // explicit disk
$this->moveFilePrivate($request, 'contract', 'contracts'); // no public URL
$this->moveFiles($request, 'docs', 'docs', 's3'); // multiple, explicit disk
Pick private for anything user-specific or contract-like; public only for marketing/avatar assets that go through a CDN.
Large file handling
Chunking is on by default (chunkSize 1 MB, parallelUploads 2, retries 3). Tune per component for big media rather than globally:
<x-filex-uploader name="video" :max-size="500" mimes="mp4,mov" :chunk-size="5242880" :retries="5" timeout="120000" />
JavaScript hooks
onSuccess/onError/onComplete/onFileAdded/onFileRemoved/onUpload take the name of a global function as a string prop:
<x-filex-uploader name="files" onSuccess="handleUploaded" />
window.handleUploaded = (file, response) => { /* update a counter, refresh a preview */ };
Maintenance
php artisan filex:cleanup-temp --dry-run # preview first, always
php artisan filex:cleanup-temp
Schedule the non-dry-run version daily; temp files expire after storage.temp_expiry_hours (24 h default) but only get reaped by this command.
$request->file('avatar') returns null. This is the number-one trap. Filex fields are strings; Laravel's store(), storeAs(), and UploadedFile methods do not apply. Use moveFile()/moveFiles() or Filex::moveFile().
Never store a temp/ path in the database. Temp files are deleted by the cleanup command. If moveFile() returns null (missing temp file, expired, or failed validation), handle it — don't fall through to $request->avatar.
Validate with ValidFileUpload, not starts_with:temp/. A plain prefix check is path-traversal vulnerable (temp/../../.env). The rule class re-checks the file exists, is inside the temp directory, and matches MIME/extension/size.
Client-side restrictions are cosmetic. mimes and :max-size on the component are UX guardrails. The server-side rule in the controller is the only enforcement that matters. Set both, and keep them in sync — mismatches produce confusing "upload succeeded but validation failed" flows.
@filexAssets exactly once. Twice causes duplicated Dropzone registration and broken drop zones; zero times causes silent AJAX failures with no console error beyond a 404.
No enctype="multipart/form-data" needed. Files travel via AJAX to temp/ before submit; the form only posts path strings. Adding the enctype is harmless but misleading to the next developer.
Edit forms need nullable, not required, plus the str_starts_with($value, 'temp/') guard. Otherwise re-saving a record with untouched files either fails validation or re-moves nonexistent temp paths.
dimensions accepts either a string ("min_width=200,min_height=200") or an array, but the array keys use underscores (min_width, max_height) while the Blade prop in the README shows nested arrays — pick one form and stay consistent within a project, and verify against your installed version.
Visibility must match where you serve from. Moving an avatar with moveFilePrivate() and then rendering Storage::url($path) gives a broken image. Public files need public visibility (and storage:link); private files need signed URLs or a streaming controller.
Chunk size must sit under upload_max_filesize/post_max_size — but chunking means individual requests are small, so the real ceiling is max_file_size in config/filex.php plus memory (README asks for 256 MB, 512 MB recommended for large media). Bump PHP limits and the config together.
Quarantine produces "file not found" downstream. Files flagged by security.suspicious_detection get quarantined rather than moved; moveFile() returns null. If users report inexplicable failures on specific PDFs/ZIPs, check the quarantine directory and the package log channel before assuming a bug.
Route prefix collisions. routes.prefix defaults to filex. If you already own /filex, change it in config/filex.php and republish assets (php artisan filex:install --only-assets) — the compiled JS embeds the endpoints.
onSuccess handlers must be globals, defined before the component initialises. A handler defined inside a deferred module or a DOMContentLoaded callback may not exist when Dropzone fires.
Debug prop for silent failures. :debug="true" on the component plus tail -f storage/logs/laravel.log is the fastest path when uploads stall at 0% (usually missing assets or a CSRF/middleware issue on the upload route) versus 100%-but-no-file (usually a validation rejection).
Add your own middleware to the upload routes. routes.middleware in config is where auth belongs if uploads should require login — the package does not assume it.
Maturity caveat. At 7 stars and zero dependents, treat this as a young package: pin the exact version in composer.json, read CHANGELOG.md before upgrading (the last release is dated 2026-09-20 and the API has room to shift), and wrap moveFile()/moveFiles() behind a thin application-level service so a future swap to plain UploadedFile handling is a one-file change rather than a grep across every controller.
How can I help you explore Laravel packages today?