jdz / mediamanager
Framework-agnostic media library management: browse, reshape and fill a media folder, with upload validation, watermarking and picker trees
Requires
- php: >=8.2
- imagine/imagine: ^1.3
- symfony/filesystem: ^7.4
- symfony/finder: ^7.4
- symfony/http-foundation: ^7.4
- symfony/string: ^7.4
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-10 22:38:13 UTC
README
Framework-agnostic media library management: browse, reshape and fill a media folder — plus upload validation, watermarking and folder trees for pickers.
Installation
composer require jdz/mediamanager
MediaManager
JDZ\MediaManager\Manager\MediaManager is the whole API. Point it at a folder and it owns everything under it.
use JDZ\MediaManager\Manager\MediaManager; use JDZ\MediaManager\ValueObject\MediaConfig; $manager = new MediaManager(MediaConfig::fromArray([ 'rootPath' => $publicPath . 'media/', 'systemFolders' => ['pages', 'documents'], // structural, cannot be moved/renamed/deleted 'extsImage' => ['png', 'gif', 'jpg', 'jpeg'], 'extsDocument' => ['pdf'], 'mimesImage' => ['image/png', 'image/gif', 'image/jpeg'], 'mimesDocument' => ['application/pdf'], 'maxWeightImage' => 2, // Mo, per file 'maxWeightDocument' => 10, // Mo, per file 'maxWeightFiles' => 3000, // Mo, whole library (0 = no quota) 'maxNumFiles' => 6000, // whole library (0 = no quota) 'maxPictureLongSide' => 1200, // px, images above are downscaled on upload 'protectPath' => $publicPath . 'protect/', 'watermarkPath' => $publicPath . 'media/nepascopier.png', ]));
Browsing
$manager->getFilesystem('photos/2024', 'images', $thumbResolver); // ['value' => 'photos/2024', 'name' => '2024', 'previous' => 'photos', // 'folders' => [['value','name','previous','type','icon'], ...], // 'files' => [['value','name','ext','thumb'], ...], // 'errors' => []] // [['key' => 'MEDIAMANAGER_ERROR_ROOT_NOT_FOUND', 'params' => [...]]] when the root folder is missing $manager->getBreadcrumbs('photos/2024'); // [['folder' => '', 'title' => 'Medias'], ['folder' => 'photos', ...], ...] $manager->getFolderSelectList(); // [['value','name','level'], ...], flattened, 'Racine' first $manager->getTree('photos/2024'); // ROOT node + nested children, active branch flagged $manager->getSelectorList('', '../media/', $imageResolver); // images grouped per folder, for a picker
Thumbnails are not this package's business. getFilesystem() and getSelectorList() take optional resolvers so the consumer plugs in its own image pipeline:
$thumbResolver = fn (string $relPath, string $fileName): ?string => $myImager->thumb($relPath, 150); $imageResolver = fn (string $relPath): ?array => ['thumb' => ..., 'orientation' => 'landscape']; // null drops the file
Reshaping
Every mutation returns an OperationResult.
$result = $manager->moveFolder('photos/2024', 'archive'); if (false === $result->isSuccess()) { echo $translator->translate($result->key, $result->params); } else { $newPath = $result->get('folder'); }
$manager->createFolder('photos', '2025'); $manager->renameFolder('photos/2024', 'archives-2024'); // data: folder $manager->moveFolder('photos/2024', 'archive'); // data: folder $manager->deleteFolder('photos/2024'); // data: parent — refuses non-empty $manager->renameFile('photos', 'img.jpg', 'sunset'); // data: file — extension is preserved $manager->moveFile('photos', 'img.jpg', 'archive'); // data: folder, file $manager->deleteFile('photos', 'img.jpg'); $manager->resolveDownloadPath('photos', 'img.jpg'); // absolute path, or null
Uploading
$result = $manager->upload($request->files->get('fileUploadName'), 'photos', $force); if (true === $result->isSuccess()) { echo $result->filename; // slugged, collision-free, downscaled if needed echo $result->value(); // 'photos/mon-image.jpg' }
What gets in is decided by the name the file will be stored under and by its bytes, never by the mime type the browser announced:
- the extension must sit in
extsImageorextsDocument(compared lowercased; no extension, no upload) — otherwiseMEDIAMANAGER_ERROR_UPLOAD_FILE_UNAUTH_EXT; - the mime
finforeads off the bytes must belong to that extension's family (jpg↔image/jpeg,pdf↔application/pdf, the OOXML / OpenDocument families, …) — a PNG renamed.jpgor a script named.pngisMEDIAMANAGER_ERROR_UPLOAD_EXTENSION. An extension the built-in table does not know falls back on the configuredmimes*lists.
jpeg and jfif are stored as jpg. Weight is checked against the config, the name is slugged (camelCase split, transliterated, lowercased, dash-separated), collisions get -1, -2, -3 unless $force, then oversized pictures are downscaled in place (GD via Imagine, EXIF autorotate). A failed resize never fails the upload. The mimes* lists still feed Dropzone's acceptedFiles (getDropzoneParams()).
Library quotas are enforced up front — $manager->getStats() exposes the counters and the verdict:
$stats = $manager->getStats(); // numFiles, weightFiles (Mo), maxNumFiles, maxWeightFiles, uploadable, errors
Watermarking
$manager->protectImage('photos', 'img.jpg'); // backs the original up, stamps the watermark over it $manager->unprotectImage('photos', 'img.jpg'); // restores it, drops the backup $manager->isProtected('photos', 'img.jpg');
A backup is filed under the file's library-relative path: photos/2024/img.jpg backs up to <protectPath>/photos/2024/img.jpg. Backups follow their file through renameFile(), moveFile(), renameFolder() and moveFolder(), and go away with deleteFile() / deleteFolder(). A backup that cannot follow its file turns the operation's result into MEDIAMANAGER_ERROR_OPERATION_FAILED (the file itself has moved).
Coming from 2.x, where backups were flat (<protectPath>/img.jpg), move them once:
php vendor/bin/migrate-protect public/media public/protect # dry run: what would move, what needs a human php vendor/bin/migrate-protect public/media public/protect --write # move the unambiguous ones
A flat backup moves when exactly one file of that name exists in the library and its slot is free. The rest is reported and left alone: the same name in several folders (settle by hand), no such file (orphan), a slot already taken. Same report from $manager->migrateProtectBackups($write).
Paths and safety
Folder paths travel through the browser with their slashes swapped for [-]. decodePath() / encodePath() handle the codec; run every inbound folder value through decodePath().
Every path is canonicalized and checked to be inside the root before anything touches the disk — .. segments, absolute paths and directory parts in file names are refused with MEDIAMANAGER_ERROR_INVALID_PATH. Paths stay forward-slashed on every platform.
Translation keys
The package never renders a message. It returns keys, which the consumer translates:
| Success | MEDIAMANAGER_SUCCESS_DIRCREATE, _DIRRENAME, _DIRMOVE, _DIRDELETE, _FILRENAME, _FILMOVE, _FILDELETE (%filename%), _PROTECT, _UNPROTECT |
| Folders | MEDIAMANAGER_ERROR_ROOT_CANNOT_BE_CHANGED, _ROOT_FOLDERS_CANNOT_BE_CHANGED, _ENTER_FOLDER_NAME, _ENTER_FOLDER_NEW_NAME, _FOLDER_NAME_UNCHANGED, _FOLDER_NOT_EMPTY, _SOURCE_FOLDER_INVALID, _SOURCE_FOLDER_NOT_FOUND, _DESTINATION_FOLDER_NOT_FOUND, _DESTINATION_FOLDER_ALREADY_EXISTS, _CANNOT_MOVE_INTO_ITSELF |
| Files | MEDIAMANAGER_ERROR_NO_FILE_SPECIFIED, _ENTER_FILE_NEW_NAME, _FILE_NAME_UNCHANGED, _SOURCE_FILE_NOT_FOUND, _DESTINATION_FILE_ALREADY_EXISTS, _SAME_FILEPATH, _NOT_AN_IMAGE, _PROTECT_ORIGINAL_NOT_FOUND |
| Uploads | MEDIAMANAGER_ERROR_UPLOAD_FILE_TOO_BIG (%maxFileSize%), _UPLOAD_FILE_UNAUTH_EXT (%authExts%), _UPLOAD_EXTENSION (%ext%, %mime% — the bytes do not match the extension), _UPLOAD_TOO_MANY_FILES (%maxFileCount%), _UPLOAD_FILESYSTEM_FULL (%maxWeightFiles%) |
| Generic | MEDIAMANAGER_ERROR_INVALID_PATH, MEDIAMANAGER_ERROR_ROOT_NOT_FOUND (%path%), MEDIAMANAGER_ERROR_OPERATION_FAILED (%error%) |
Trees
ImageTree builds a nested folders/files structure; DocumentTree builds a flat list with per-type icons. Both skip entries whose basename starts with _, and accept ignore lists. Reachable directly, or through getRedactorImageTree() / getRedactorDocumentTree().
use JDZ\MediaManager\Tree\ImageTree; use JDZ\MediaManager\Tree\DocumentTree; $images = (new ImageTree()) ->setBasePath($publicPath . 'media/') ->setBaseUrl('../media/') ->setIgnoreFolders(['template']) ->setIgnoreFiles(['share.jpg']) ->getTree(); // ['folders' => [['label','path','files' => [...]], ...], 'files' => [['url','id','title','ext'], ...]] $documents = (new DocumentTree()) ->setBasePath($publicPath . 'media/') ->setBaseUrl('../media/') ->getTree(); // [['id','title','text','icon','url','ext'], ...]
Upgrading from 2.x
- Uploads are checked on the stored extension and the bytes. The browser's mime type no longer lets a file in: list every extension you accept in
extsImage/extsDocument(addjfifif your users drop those), and expect the newMEDIAMANAGER_ERROR_UPLOAD_EXTENSIONkey (%ext%,%mime%) in your translations. isProtected(string $folder, string $fileName)takes the folder, likeprotectImage(); the one-argument form is gone.- Backups moved from
<protectPath>/<name>to<protectPath>/<relative path>: runvendor/bin/migrate-protectonce per library (see Watermarking). Until thenunprotectImage()answersMEDIAMANAGER_ERROR_PROTECT_ORIGINAL_NOT_FOUNDfor the old slots andprotectImage()would back the watermarked file up as if it were pristine. decodePath()keeps a+as a plus (rawurldecode);renameFolder()refuses a name holding/,\or[-];isSystemFolder()ignores case; a missing root folder makesgetFilesystem()/getStats()answer empty results carryingMEDIAMANAGER_ERROR_ROOT_NOT_FOUNDinstead of throwing (getFilesystem()now always has anerrorskey).
Upgrading from 1.x
JDZ\MediaManager\Upload is gone — its work is done by MediaManager::upload(), which takes an explicit target folder, enforces library quotas and returns an UploadResult instead of throwing. Tree/ is unchanged.
Tests
composer test
Changelog
- 3.0.0 - Uploads are accepted on the stored extension (
exts*lists) and the mimefinforeads off the bytes, never on the announced mime (newMEDIAMANAGER_ERROR_UPLOAD_EXTENSION;jfifstored asjpg); protect backups are filed under the file's relative path, follow renames, moves and deletes,isProtected(folder, file),bin/migrate-protectfor the 2.x flat slots;decodePath()keeps+;renameFolder()refuses separators; the system-folder guard ignores case; a missing root answersMEDIAMANAGER_ERROR_ROOT_NOT_FOUNDinstead of throwing. - 2.0.1 - A system folder spelled
photos/../pagesor./pagescould be renamed, moved or deleted: the guard judges where the path lands. - 2.0.0 -
Uploadreplaced byMediaManager::upload()(explicit target folder, library quotas,UploadResult); the manager layer. - 1.0.0 - Initial release, from Callisto.
License
MIT — see LICENSE.