Search by

jdz / mediamanager

jdz

Framework-agnostic media library management: browse, reshape and fill a media folder, with upload validation, watermarking and picker trees

3.0.0 2026-10-10 22:37 UTC

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:

  1. the extension must sit in extsImage or extsDocument (compared lowercased; no extension, no upload) — otherwise MEDIAMANAGER_ERROR_UPLOAD_FILE_UNAUTH_EXT;
  2. the mime finfo reads off the bytes must belong to that extension's family (jpg ↔ image/jpeg, pdf ↔ application/pdf, the OOXML / OpenDocument families, …) — a PNG renamed .jpg or a script named .png is MEDIAMANAGER_ERROR_UPLOAD_EXTENSION. An extension the built-in table does not know falls back on the configured mimes* 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 (add jfif if your users drop those), and expect the new MEDIAMANAGER_ERROR_UPLOAD_EXTENSION key (%ext%, %mime%) in your translations.
  • isProtected(string $folder, string $fileName) takes the folder, like protectImage(); the one-argument form is gone.
  • Backups moved from <protectPath>/<name> to <protectPath>/<relative path>: run vendor/bin/migrate-protect once per library (see Watermarking). Until then unprotectImage() answers MEDIAMANAGER_ERROR_PROTECT_ORIGINAL_NOT_FOUND for the old slots and protectImage() 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 makes getFilesystem() / getStats() answer empty results carrying MEDIAMANAGER_ERROR_ROOT_NOT_FOUND instead of throwing (getFilesystem() now always has an errors key).

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 mime finfo reads off the bytes, never on the announced mime (new MEDIAMANAGER_ERROR_UPLOAD_EXTENSION; jfif stored as jpg); protect backups are filed under the file's relative path, follow renames, moves and deletes, isProtected(folder, file), bin/migrate-protect for the 2.x flat slots; decodePath() keeps +; renameFolder() refuses separators; the system-folder guard ignores case; a missing root answers MEDIAMANAGER_ERROR_ROOT_NOT_FOUND instead of throwing.
  • 2.0.1 - A system folder spelled photos/../pages or ./pages could be renamed, moved or deleted: the guard judges where the path lands.
  • 2.0.0 - Upload replaced by MediaManager::upload() (explicit target folder, library quotas, UploadResult); the manager layer.
  • 1.0.0 - Initial release, from Callisto.

License

MIT — see LICENSE.