torann/mediasort
MediaSort is a Laravel package for automatically organizing photo and video libraries. It reads media metadata such as capture date, camera, and file type, then sorts files into configurable folder structures to keep large collections tidy and easy to browse.
Install, then read the source before writing app code.
composer require torann/mediasort:^2.0
php artisan vendor:publish --provider="Torann\MediaSort\MediaSortServiceProvider"
cat config/mediasort.php
ls vendor/torann/mediasort/src
The published config is the real specification for this package — more so than the README. Read it first, because the whole value proposition is "declare where files go, stop writing if ($ext === 'jpg') branches in every controller." Confirm three things from src/ before integrating:
UploadedFile, a path string, or a model attribute?).Storage-style object. This determines your schema.First use case: a single upload path — user avatars, post images, or support-ticket attachments — sorted into type-based folders under storage/app/public, with the resulting relative path saved on the model. Do not start with multi-tenant paths or dynamic date bucketing; get one flat rule working end to end first.
Minimal integration:
// config/mediasort.php — verify key names against the published file
'rules' => [
'images' => ['jpg', 'jpeg', 'png', 'webp', 'gif'],
'documents' => ['pdf', 'doc', 'docx'],
'spreadsheets'=> ['xls', 'xlsx', 'csv'],
],
'destinations' => [
'images' => 'uploads/images',
'documents' => 'uploads/documents',
'spreadsheets' => 'uploads/spreadsheets',
],
'default' => 'uploads/misc',
$path = MediaSort::store($request->file('attachment')); // verify actual method name
$ticket->update(['attachment_path' => $path]);
Then assert in a test that a .png lands in uploads/images, an unknown .xyz lands in uploads/misc, and the returned path is what you'd feed to Storage::url().
Config is the registry — keep it that way. The moment you start branching on file type in a controller, the package is earning nothing. Every new category should be a two-line config diff (extensions + destination), reviewed like a data change rather than code.
Call the sorter from one place. Put it behind a FormRequest rule plus a single service call, so every upload flows through the same validation → sort → persist sequence:
class StoreAttachment
{
public function __construct(private MediaSorter $sorter) {}
public function handle(Model $parent, UploadedFile $file): Attachment
{
$path = $this->sorter->store($file); // package call, isolated
return $parent->attachments()->create([
'disk' => config('mediasort.disk'),
'path' => $path,
'original_name' => $file->getClientOriginalName(),
'mime' => $file->getMimeType(),
'size' => $file->getSize(),
]);
}
}
That wrapper is an hour of work and converts "the package changed its API" from a codebase-wide grep into a one-file edit — which matters at 20 stars and 0 dependents.
Persist the path, not the file's fate. Store the resolved relative path, disk name, and original filename in your own table. Never re-derive the destination by re-running the rule at read time: rules change, and old files would then point at folders that never existed.
Custom handlers for anything stateful. Extension/MIME tables can't express "year/month folders," "tenant-prefixed paths," or "quarantine executables." The package exposes handlers for exactly this — implement one small handler per policy, not per file type, and keep the config flat.
move vs copy, deliberately. Use move for the normal upload flow (the temp file is disposable, and it avoids double disk cost). Use copy only when you're re-filing an existing asset that something else still references — e.g. a migration that reorganizes a legacy folder. Pick one per code path and note it in the docblock; mixed semantics cause the classic "the thumbnail worked, then it 404'd" bug.
Backfill legacy files with a queued job, not a controller. Iterate existing rows, run each through the same sorter, record the new path, verify the new file exists, then delete the old one. Idempotency matters — if the job dies halfway, re-running must not orphan files.
Test with a faked disk. Every handler and rule deserves a test on Storage::fake() asserting final path, and one asserting the fallback for an unrecognized extension. This is cheap and it's the only thing that will catch a config typo before a user uploads.
The docs and the changelog disagree — trust neither. The package description ("rule-based sorting by extension, MIME type, or custom callbacks") and the 2.0.9 note ("a way to check if a file was uploaded before it's processed") describe related but not identical capabilities, and the bundled review material reads as if this were a sortable-position trait. It isn't. Read src/ for twenty minutes and you'll save yourself a day of building against imagined APIs.
config:cache in production is a landmine with closures. If any rule, destination, or handler in your config is a closure or anonymous class, it will serialize wrong — or not at all — under php artisan config:cache. Keep config to scalars and class-name strings; put logic in named handler classes.
Extension vs. MIME — decide which is authoritative. Users rename payload.php to photo.jpg. Validate the real MIME type before you let it into uploads/images, and prefer resolving the rule from MIME where the package allows it. If you must rule on extension, lowercase it first — IMG.JPG and img.jpg will otherwise hit different branches.
Filename collisions silently overwrite. Two users uploading avatar.png will land on the same path unless the package (or your wrapper) hashes or UUIDs the name. Confirm this in src/ and, if it doesn't, wrap the call and store under Str::uuid() yourself. This is the single most likely production incident with a file-sorting package.
Public access is a separate problem from sorting. The package puts files in the right folder; it does not make them reachable. Run php artisan storage:link locally, set the correct visibility on S3 (public vs private are not interchangeable), and never assume Storage::url() works because the path looks right.
Disk configuration differs by environment. Local uses local/public; production often uses s3. Anything referencing the disk path directly (/storage/uploads/...) breaks on S3. Always resolve through Storage::disk($disk)->url($path) and store the disk name alongside the path.
"Was this uploaded before it's processed?" is your retry guard. The 2.0.9 helper exists so a re-submitted form or a retried job doesn't duplicate a file. Use it at the top of any handler that runs in a queue or behind a "save and retry" button.
Deletion is your responsibility. When a model is deleted, nothing in this package knows to unlink the file. If you don't add a deleting model event or a cleanup job, storage grows forever — quietly, and expensively on S3.
Debugging "the file went to the wrong folder": log four values in one line — original filename, detected extension, detected MIME, and the resolved destination rule. The package's decision is a lookup; a support ticket is unactionable without that lookup logged. Temporarily dumping the merged config is also worth the thirty seconds.
Where to extend rather than fight. If you need virus scanning, image resizing, or CDN invalidation as part of the sort, write a handler that composes the package's move with your own step. Don't fork the package for a single behavior — fork only for an API you can't wrap.
Pin the version and own the risk explicitly. One maintainer, 20 stars, zero dependents, BSD-2. That's tolerable for an admin-side upload helper behind a wrapper, and not tolerable as the only thing standing between you and a broken customer-facing media pipeline. composer require torann/mediasort:^2.0, a named owner in the dependency register, and a note that a vendored fork is acceptable — the code is small enough that the downside is genuinely capped.
How can I help you explore Laravel packages today?