emaia/laravel-mediaman
Laravel MediaMan is a UI-agnostic media manager for Laravel: upload files via a fluent MediaUploader, organize into virtual collections, run automatic conversions, and attach media to any model with channel-based associations. Ideal for apps and APIs.
Adds immutable-safe conversion generation paths with signed manifests, lifecycle coordination, and retention-aware pruning.
MEDIAMAN_CONVERSION_VERSIONING=generation, persisted active metadata, independent retention, and in-progress timeout settings.mediaman:prune-conversion-generations, a dry-run-first command for inactive conversion generations.--force requests preserve force semantics, and versioned skips emit no completion event.mediaman:clear-conversions now scans all selected media records, including records whose MIME type changed after conversions were created. Legacy skips retain their existing completion-event behavior; versioned skips do not emit completion events.false results as failures, merge manifests through a fresh locked row, and preserve the previous active generation until replacement publication succeeds.ParsesMediaIds remains as a deprecated compatibility alias while package commands use opaque media keys.Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v3.1.0...v3.2.0
Adds immutable-safe responsive generation paths with atomic publication, lifecycle coordination, and retention-aware pruning.
MEDIAMAN_RESPONSIVE_VERSIONING=generation. Every complete generation is written beneath one ULID directory and published through a single locked media save; legacy stable paths remain the default.mediaman:prune-responsive-generations, a dry-run-first command that removes inactive and abandoned ULID generations after retention while protecting active manifests and in-progress work.mediaman:doctor and mediaman:stats --responsive now report generation strategy, retention, timeout, and legacy/versioned manifest coverage.ResponsiveGenerationResult describing no-op, complete, or partial publication outcomes. Hard failures continue to throw so queued retries remain unchanged.ResponsiveImagesGenerated is emitted only after a queued job publishes a manifest; no-op jobs for non-raster or missing source media no longer emit it. The event now exposes the structured result as $event->result.mediaman.queue, regardless of whether dispatch originates from a command, upload, model helper, or channel attachment.mediaman:clear-responsive scans every selected media record rather than only raster images, allowing stale responsive metadata and retry tombstones to be cleared after a record's MIME type changes.deleteQuietly() and forceDeleteQuietly() now suppress model/package events but still remove physical media files. Hard-delete cleanup and MediaDeleted are deferred until an enclosing application transaction commits; rollback preserves both the row and its files.false result as failure and retain the previous active manifest when a versioned replacement cannot be published.Media::copy() rebuilds responsive paths and URLs for the target media, copies only manifest-published variants, validates storage results, and rolls the target back when attachment or file copying fails.mediaman.queue.APP_PREVIOUS_KEYS key ring during APP_KEY rotation, and responsive metadata is validated before path derivation.Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v3.0.2...v3.1.0
Restores a working public-disk default for fresh Laravel installations and keeps queued conversion URLs aligned with their canonical output extension.
public disk, making the documented storage:link installation flow produce working getUrl() values on standard Laravel 12/13 applications. Explicit disk configuration and the opt-in null fallback to filesystems.default remain supported..jfif uploads encoded as JPEG .jpg files.Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v3.0.1...v3.0.2
Small follow-up to v3.0.0: documentation sweep and a friendlier dependency story for MediaUploader::fromUrl().
ext-curl is now optionalThe package previously hard-required ext-curl in composer.json because MediaUploader::fromUrl() needs it for IP-pinned SSRF protection via CURLOPT_RESOLVE. Other upload sources (fromRequest, fromDisk, fromBase64, fromStream, fromString) don't.
ext-curl is now listed under suggest. Calling fromUrl() on a system without it throws a clear RuntimeException instead of a fatal undefined-constant error:
MediaUploader::fromUrl() requires the PHP ext-curl extension. Install/enable it, or use another upload source (fromRequest, fromDisk, fromBase64, fromStream, fromString).
Composer install no longer blocks on ext-curl for apps that don't use fromUrl().
Channels, Conversions) to match Responsive Images and the docs tabledocs/installation.md into UPGRADING.md (new "v2.0 – v2.12 catch-up" section + the v2.13 LQIP payload note folded into the existing v2.13 – v2.17 catch-up)docs/configuration.md now documents conversions.disk and responsive_images.disk, and ends with a new Configuration reference (all keys) appendix — a single table covering every key in config/mediaman.php (type, default, env var, link back to its prose section)docs/installation.md Next steps now includes Conversions and Responsive imagesFull Changelog: https://github.com/emaia/laravel-mediaman/compare/v3.0.0...v3.0.1
Consolidates the API surface accumulated since v2, lands the audit findings from a full security pass, and adds operational tooling so adopters can verify their stack before the first upload.
The v2 trio PathGenerator + UrlGenerator + FileNamer collapsed into a single MediaResolver interface. Most customizations touched all three together (changing the directory implies changing the URL), so the indirection added cost without giving you anything back.
- 'generators' => [
- 'path' => CustomPathGenerator::class,
- 'url' => CustomUrlGenerator::class,
- 'file_namer' => CustomFileNamer::class,
- ],
+ 'resolver' => CustomMediaResolver::class,
Method names dropped the get prefix to match Laravel idiom: getDirectory → directory, getUrl → url, etc. DefaultMediaResolver preserves v2 behavior bit-for-bit — extend it and override only the methods you actually need.
MediaChannel::acceptsFile() registers per-channel validators that run at attach time, not upload time. Rules stack with implicit AND, can be named for error reporting, and have access to the owning model when they need it.
$post->addMediaChannel('gallery')
->acceptsFile(fn (Media $m) => $m->isOfType(MediaType::IMAGE), 'must-be-image')
->acceptsFile(fn (Media $m, Post $p) => $p->getMedia('gallery')->count() < 5, 'max-5');
Failure throws MediaNotAcceptedByChannel with $e->channel, $e->rule, and $e->mediaId for programmatic handling.
Originals stay on durable cloud storage (S3/GCS); variants land on a hot local disk that the <picture> element hits every page view. Real savings for VPS-hosted apps with substantial traffic — S3 egress + GET-request fees disappear from the per-page-view path.
Conversion::register('thumb', fn ($img) => $img->cover(64, 64)); // → media's disk
Conversion::register('archive', fn ($img) => $img->scaleDown(4096), disk: 's3-glacier'); // override
// Or globally:
'conversions' => ['disk' => 'public'],
'responsive_images' => ['disk' => 'public'],
Resolution chain for conversions: per-registration override → mediaman.conversions.disk → media's own disk. mediaman:clean and mediaman:doctor probe every disk in use; MediaResolver::isManagedDirectory() lets custom resolvers participate in orphan cleanup with their own directory shape.
MediaFormat::HEIC joins responsiveFormats() and preferredOrder(). Each variant encode runs in its own try/catch, so a driver without HEIC support skips that variant with a Log::warning instead of aborting the whole batch.
'formats' => ['avif', 'heic', 'webp', 'jpg'], // graceful degradation per format per driver
Zero-byte encodes (imagick without the libheif HEVC plugin) throw and are isolated by the same mechanism — no more .heic files of size 0 landing on disk.
responsive_images.quality now accepts an array keyed by format. AVIF can run aggressively low (modern encoder, high efficiency); JPG/WebP want the comfortable 80–85 zone.
'quality' => ['avif' => 50, 'webp' => 85, 'jpg' => 80],
Missing entries for a lossy format declared in formats throw InvalidArgumentException at generation time — typos can't silently fall back. Lossless formats (PNG/GIF) don't need an entry. Per-upload override via withQuality(int|array).
Two new entry points for cases without a UploadedFile:
$media = MediaUploader::fromString($pdfBytes, 'invoice.pdf')->upload();
$media = MediaUploader::fromStream($resource, 'video.mp4')->upload();
fromStream is caller-owns-the-stream — reads through to a temp file but doesn't fclose(). Suits PSR-7 detached streams, SFTP wrappers, and content piped from another process.
mediaman.url.version_query (bool) replaced by mediaman.url.versioning (enum):
'url' => [
'versioning' => 'timestamp', // appends ?v={updated_at} to all URLs
'prefix' => 'https://cdn.example.com',
],
Supports false (default) and 'timestamp'. The legacy key is no longer read — rename is mandatory.
ImageManipulator::manipulate() isolates each conversion in its own try/catch. Partial-batch failures emit ConversionCompleted (successful set) + one ConversionFailed per failure. All-failed defers ConversionFailed to the queue's failed() hook so listeners only act after Laravel exhausts retries — no racing the queue's own retry logic.
Event::listen(function (ConversionFailed $event) {
// Reached here = queue gave up. Act decisively.
AuditLog::create([
'media_id' => $event->media->id,
'conversion' => $event->conversion,
'error' => $event->exception->getMessage(),
]);
});
Helper: $event->reschedule(60) queues a one-shot retry of just the failed conversion.
MediaUploader::upload() runs the database row, the file write, and the collection attach in a single DB::transaction. If the disk write returns false or throws (S3 permission denied, full disk), the row rolls back and any partial file is removed — no orphan row, no orphan file.
try {
$media = MediaUploader::source($file)->upload();
} catch (MediaFileWriteFailed $e) {
// $e->disk and $e->path are public readonly
return back()->withError("Could not write to {$e->disk}.");
}
The media_url / media_uri accessors also route through getUrl() now, picking up url.prefix and url.versioning consistently with everything else.
A 15-commit pass landing the audit findings. Highlights — full list in UPGRADING.md:
svg.enabled = true + a sanitizer (enshrined/svg-sanitize recommended).min_file_size = 1 by default rejects zero-byte uploads (ghost records pointing at empty files).fromUrl trusts content, not headers — extension and MIME are sniffed from the downloaded bytes, not the remote Content-Type. Closes a path where a malicious server lied about Content-Type to bypass the extension blocklist.UploadFailed exception surfaces PHP-level upload errors (upload_max_filesize exceeded, partial, no_tmp_dir) with the actual cause + the original UPLOAD_ERR_* code on $e->phpUploadErrorCode. Previously masked as FileSizeExceeded::belowMinimum(0, 1) — technically true (0 < 1) but completely wrong about the cause.PerformConversions and responsive image generation silently skip non-raster media (SVG, PSD, ICO) instead of dispatching guaranteed-to-fail jobs that retry until exhaustion. Includes Media::scopeRaster() for upfront filtering.fromDisk streamed via readStream() — a 2 GB video on S3 ingests in O(1) memory instead of needing > 2 GB of PHP RAM.MediaFormat::extensionFromMimeType() drops the silent 'jpg' fallback for unknown MIMEs (returns ?string). Exotic encoder outputs no longer write garbage bytes with a wrong-but-plausible extension.The Image driver section now probes every format in responsive_images.formats (10×10 encode), runs a real 1×1 PNG encode to catch the Vips-FFI-not-loaded false-positive, and shows the current SAPI + ffi.enable when Vips is the driver — with hints that name the actual SAPI and remind operators to verify it under whichever runtime production serves.
Catches the libheif HEVC plugin gap (the #1 onboarding pain after HEIC landed), Vips FFI misconfiguration (CLI works / browser fails), and codec gaps before they hit a real upload.
php artisan mediaman:doctor
# ...
# Image driver ............................................
# Effective ............ ✓ Intervention\Image\Drivers\Vips\Driver
# Probe ........... ✓ driver encodes a 1×1 test image
# Probe SAPI .................. · cli (ffi.enable=true)
# Format probe (avif) ............... ✓ encodes ✓
# Format probe (heic) ⚠ encoder returned zero bytes — install libheif HEVC encoder plugin
20 BCs total. Most apps need 1–2 config lines plus an exception catch. See UPGRADING.md for the section-by-section migration guide and rationale per change.
Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v2.18.0...v3.0.0
MediaPrunedFromCollection event — dispatched by MediaCollection::enforceMaxItems() whenever the cap configured via onlyKeepLatest() / singleFile() causes older media to be detached. Carries $event->collection and $event->detachedMediaIds so listeners can record an audit entry, notify the owning user, or clean up downstream state. Auto-prune itself is unchanged — the Media records are still only detached, never deleted — the event just makes it observable. See Collections → Auto-prune oldest.HasMedia::forgetMediaCache(?string $channel = null): self — public escape hatch to invalidate the in-memory media cache from outside the trait. The cache is cleared automatically by every mutation the trait owns (attachMedia, syncMedia, detachMedia, setMediaOrder, clearMediaChannel), but callers had no way to react when an external mutation — a queued job on the sync driver reusing the same model instance, a raw DB::table() insert, a sibling relation refresh — left the cache stale. Passing a channel clears that channel plus the all-channels snapshot (which would otherwise still include the channel's media); passing null clears every channel. Returns $this so it chains fluently. See Models → Cache invalidation.Casts\Json no longer emits a PHP 8.3 E_DEPRECATED notice when custom_properties is null. The cast previously called json_decode($value, true) directly on the raw column value, and json_decode(null, ...) has been deprecated since PHP 8.1. Get and set now short-circuit on null, returning null in both directions so the column roundtrips cleanly. Apps reporting deprecations (Sentry, strict-mode logs, CI test suites) stop seeing noise on every fresh Media read.Media::getCustomProperty($name, $default) now accepts any default value type. The $default parameter was previously declared as ?string, so any non-string default — including the array shapes the package itself persists under image_meta and conversion_hashes — raised a TypeError before reaching Arr::get(). The signature is now getCustomProperty(string $name, mixed $default = null): mixed, matching the actual storage contract.HasMedia::syncMedia no longer swallows domain and database exceptions. The catch (Throwable) block at the end of the method previously logged a warning and returned null for every failure, hiding MediaNotAcceptedByCollection, QueryException (deadlocks, constraint violations), and InvalidArgumentException behind a no-op result indistinguishable from "nothing to sync". Those three exception types now rethrow so callers can surface validation feedback, retry deadlocks, or fail fast on programmer error. Truly unexpected Throwables keep the existing log-and-return-null behavior — the swallowing was the bug, not the logging. This unblocks the upcoming v3 acceptsFile() channel rules, whose MediaNotAcceptedByChannel would otherwise be silently absorbed here.HasMedia::getMedia(null) no longer poisons the default channel cache. The cache key was derived as $channel ?? Media::DEFAULT_CHANNEL, so getMedia(null) (which intentionally returns media across all channels) stored its unfiltered result under the 'default' key. The next call to getMedia('default') then hit that cache and returned media from every channel instead of just the default one. The all-channels lookup is now keyed by an internal sentinel string that pivot data can never collide with, and clearMediaCache($channel) also invalidates that sentinel so detach/attach side effects do not leave a stale "all channels" snapshot behind.Media model's deleted observer fires on both soft and force deletes, so a custom Media subclass using Laravel's SoftDeletes previously had its on-disk directory wiped on a plain $media->delete() — leaving the soft-deleted record pointing at missing files and making restore() useless. The observer now skips file removal (and the MediaDeleted event) on a soft delete, and only deletes the directory on a force delete (forceDelete()) or on a model without soft deletes. The base Media model has no SoftDeletes trait, so its behavior is unchanged. See Configuration → Custom models.mediaman:generate-conversions artisan command — generate (or regenerate) registered conversions for existing media. Required --conversion=thumb,cover lists one or more registered names (validated against the ConversionRegistry; unknown names short-circuit with a clear error). Optional --media=1,3,5..10 filters by id (individual values and ranges), --collection=avatars filters by collection name. --force overwrites existing conversion files (default skips when the file is already on disk). --queue dispatches each item as a PerformConversions job instead of running synchronously. Confirmation prompt fires when the operation count (media × conversions) crosses 100. Output follows the doctor-style layout with summary counters (processed / skipped / failed). See Commands → Generate conversions.mediaman:clear-conversions artisan command — parity with clear-responsive for conversion files. Uses the same --conversion (required), --media (with range support), --collection, and --force flags. Deletes conversion directories from disk; no DB metadata to reset since conversions are filesystem-only. See Commands → Clear conversions.mediaman:stats artisan command — consolidated statistics command replacing the previous mediaman:responsive-stats. Without flags: media inventory (records, total size, image count), registered conversion names, and responsive coverage with config summary. --responsive shows the detailed responsive breakdown (total/with/without coverage, per-format configuration). --conversions shows each registered conversion with its detected output format. See Commands → Stats.MediaUploader::readImageMeta() introduced in v2.13.0: every image upload unconditionally performed a full ImageManager::decode() + resize(1,1) to extract width, height, and dominant color. The resize(1,1) averages all pixels and is especially expensive for large images (banners, covers). Width and height are now extracted via PHP's native getimagesize() (header-only, sub-millisecond), and the expensive dominant-color decode runs only when mediaman.placeholder.enabled is true. Seeders and batch uploads return to v2.12.0 performance levels without losing any functionality.UrlGuardTest DNS-dependent tests now skip gracefully when localhost.localdomain does not resolve in the test environment (containers, CI).Media::getTemporaryUrl() resolved — removed redundant method_exists() guard in favor of direct providesTemporaryUrls() call.mediaman:generate-responsive now uses the doctor-style output layout (section headers + twoColumnDetail rows). --media accepts ranges (1..10) and mixed lists (1,3..5), matching generate-conversions. --queue is now an explicit flag — passing it dispatches as queued jobs, omitting it processes inline. The previous fallthrough to mediaman.responsive_images.queue config is removed. Per-item log lines replaced by a summary (processed / failed). BC note: invocations that relied on the config default (typically queue=true) for queueing must now pass --queue explicitly; conversely, scripts that used --queue=false to force inline must drop the value (the flag is now boolean: present = queue, absent = inline). See Commands → Generate responsive.mediaman:clear-responsive now uses --force to skip the confirmation prompt instead of the previous --confirm flag (which sat inverted to Laravel's convention). Output migrated to doctor-style layout with summary counters. --media now accepts ranges (1..10) and mixed lists (1,3..5), matching generate-conversions/generate-responsive/clear-conversions. BC note: scripts passing --confirm must switch to --force.Mediaman prefix: MediamanCleanCommand → CleanCommand, MediamanDoctorCommand → DoctorCommand, MediamanPublishCommand → PublishCommand, MediamanPublishConfigCommand → PublishConfigCommand, MediamanPublishMigrationCommand → PublishMigrationCommand, MediamanRotatePathsCommand → RotatePathsCommand. The CLI signatures (mediaman:clean, mediaman:doctor, …) are unchanged. BC note: code that references these class names by FQCN (e.g. app(MediamanCleanCommand::class) or extends MediamanCleanCommand) must update.responsive-* conversions registered automatically by ResponsiveConversions::register() (responsive, responsive-optimized, responsive-custom, responsive-webp, responsive-hq), the ResponsiveConversion wrapper class, and the mediaman.responsive_images.predefined_conversions config block. These names were never documented anywhere in docs/* and were non-functional in practice: ImageManipulator::manipulate() does not branch on ResponsiveConversion instances, so calling any of them via performConversions(...) or PerformConversions::dispatch(...) silently produced no output and triggered no responsive variant generation. Use MediaUploader::generateResponsive()->withBreakpoints()->withFormats()->withQuality() at upload time or $media->generateResponsiveImages($options) on existing media — the documented, idiomatic, and actually-functional paths.mediaman:responsive-stats — replaced by the consolidated mediaman:stats command. Use mediaman:stats --responsive for the same detailed breakdown. BC note: scripts calling responsive-stats must switch to stats --responsive.mediaman:doctor artisan command — read-only health check of the MediaMan pipeline (schema migrations, default disk write/read/delete probe, public symlink verification, effective image driver, queue connection + auto-generate consistency, registered conversions count, media inventory with total bytes and responsive coverage). Useful as a smoke test after deployment, after APP_KEY rotation, or while debugging "URL returns 404 but the record exists" issues. The symlink check matches filesystems.links entries against the disk's root and confirms each link path exists and points correctly — catches the classic post-install "I forgot to run storage:link" trap. Never mutates state. Exit code is 1 only on errors (schema missing, disk inaccessible, driver constructor fails, link path squatted by a real file); warnings (missing symlink, auto_generate without worker) keep exit code at 0. See Commands → Doctor (health check).vips driver from Intervention Image 4. Auto-detection now prefers vips → imagick → gd (previously imagick → gd); set MEDIAMAN_DRIVER=vips explicitly to force it. The driver lives in a separate Composer package — install intervention/image-driver-vips and make sure ext-vips is loaded. Listed under suggest in composer.json so consumers see it without it being a hard dependency. Auto-detect runs a runtime probe (new VipsDriver) in addition to the extension/package checks so a misconfigured libvips (driver throws MissingDependencyException) falls through to imagick/gd gracefully; an explicit MEDIAMAN_DRIVER=vips still bubbles the error.PlaceholderGenerator implementations alongside the default BlurredSvgPlaceholder:
DominantColorPlaceholder — single area-weighted average color wrapped in a flat-fill SVG. ~150 bytes regardless of source size. Pure ASCII. Ideal for galleries, bandwidth-sensitive contexts, and CSS skeletons.GeometricBlurPlaceholder — N×N color grid (default 4×4) sampled from the source, rendered as <rect>s under an feGaussianBlur filter. ~2 KB at grid=4 (regardless of source size); grid=8 (~6–8 KB) trades size for visual richness. Pure ASCII, CSP-friendly. Two new config knobs under their own sub-block: mediaman.placeholder.geometric_blur.{grid_size, blur_std_deviation}.Emaia\MediaMan\Placeholders\PlaceholderGenerator. Swap via mediaman.placeholder.generator or rebind the interface (mirrors the v2.9 generators pattern). Default implementation BlurredSvgPlaceholder wraps a tiny blurred JPEG inside an SVG with the original viewBox and returns a percent-encoded data:image/svg+xml,… URI (~16% smaller than the equivalent base64 wrapper, readable in DevTools).width, height, dominant_color) is now persisted in custom_properties.image_meta for every image upload in a single decode pass, independent of the placeholder feature. The struct was previously named dimensions and held only width/height.Media::getPlaceholderColor(): ?string — hex CSS color sampled at upload (average of the source). ~10 bytes, ideal as a background-color skeleton anywhere the LQIP data URI is too heavy: email, SSR, JSON APIs, container backgrounds. Composes naturally with getPictureHtml() for a three-stage progressive paint (color → SVG LQIP → responsive image).Media::getUrlOrPlaceholder($conversion) — single-URL helper for non-srcset contexts (email HTML, JSON payloads, OG/Twitter tags, CSS background-image). Returns the conversion URL when the file exists, the LQIP data URI as fallback, and finally the original URL.getPictureHtml() always emits a <picture> wrapper, even when no responsive variants exist (<picture><img></picture>). Previously the method silently fell through to getSimpleImgHtml() and returned a bare <img> in that case — and also when only a single responsive format was configured (the default formats=['webp']), which left the rendered output without a <picture> despite the variants being there. Markup shape is now consistent across all states.<source> elements now cover every responsive format. Previously the last format was reserved for the inner <img> srcset, which mis-categorised single-format setups (e.g. WebP variants attached to a JPEG original) by mixing formats inside <img srcset>. The <img> always points at the original file now, with its native width as a single srcset candidate.getSrcset() filters out responsive entries with empty URL or zero width before assembling the string — degenerate data no longer surfaces as malformed <source> tags.viewBox pins the aspect ratio before any pixel data arrives, eliminating CLS, working inside <picture> (every <source srcset> now carries the placeholder), and removing the previous CSP friction from inline style="background-image:…" injection.getPictureHtml() and getSimpleImgHtml() always populate width and height on the <img> (from custom_properties.image_meta), not only with sizes='auto' — CLS is fixed even when LQIP is off. The sizes='auto' branch no longer overrides width/height with the smallest responsive variant.decoding="async" is now set by default on the rendered <img>. Override per call with ['decoding' => 'sync']. loading="lazy" is not defaulted (it hurts LCP on above-the-fold images); opt in per call where appropriate.getImageWidth() / getImageHeight() read from custom_properties.image_meta first, then fall back to responsive variants, then lazy-decode.Media::PROPERTY_DIMENSIONS constant renamed to Media::PROPERTY_IMAGE_META (and the underlying key dimensions → image_meta) to make room for the additional fields. Pre-v2.13 records keep working — the lazy fallback re-populates the new key on first read.blurred_svg sub-block: mediaman.placeholder.{width, blur, quality} → mediaman.placeholder.blurred_svg.{width, blur, quality}. Per-generator knobs are now scoped to their own namespace — swapping generator to a different implementation no longer silently reuses or ignores unrelated keys.PlaceholderGenerator service-container bind resolves the configured class lazily via a closure (instead of capturing the FQCN at register time). Apps and tests can swap the implementation via Config::set('mediaman.placeholder.generator', …) without having to call app()->instance() to force a rebind.style="background-image:url('data:image/jpeg;…')" injection in getPictureHtml() / getSimpleImgHtml().getSimpleImgHtml() is unchanged and remains the explicit escape hatch when callers need a bare <img> (email templates, etc.).Media uploaded with v2.11 / v2.12 still hold the old JPEG payload in custom_properties.placeholder. Re-upload affected media to refresh; non-refreshed records keep rendering the JPEG inline as a degraded fallback.
mediaman:rotate-paths artisan command — renames the on-disk media directories after an APP_KEY rotation. Iterates Media records, computes the path under the previous key vs the current key, and physically moves files when they differ. Dry-run by default; --force applies the moves; --disk and --media scope the operation; idempotent across re-runs. See Security → APP_KEY rotation and Commands → Rotate media paths after APP_KEY rotation.mediaman.driver default is now null and auto-detected at boot — imagick when ext-imagick is loaded, gd otherwise. Previously hardcoded to imagick, which threw InvalidArgumentException at runtime on servers without ext-imagick. Existing installations that set MEDIAMAN_DRIVER explicitly are unaffected.mediaman.disk default is now null and falls back to config('filesystems.default'). Previously hardcoded to 'public'. Existing installations that set the value explicitly are unaffected; in practice Laravel's default disk is also 'public' in fresh apps, so behavior matches for the common case.phpstan and pint --test jobs in parallel with the test matrix. Previously only pest ran on PRs..github/workflows/release.yml creates a GitHub Release automatically when a v* tag is pushed, extracting the matching section from CHANGELOG.md. The manual gh release create --notes-file … step is no longer needed..github/PULL_REQUEST_TEMPLATE.md prompts contributors to update the CHANGELOG and relevant docs.MEDIAMAN_PLACEHOLDER_ENABLED=true to have image uploads generate a tiny blurred JPEG (~2 KB) stored as a base64 data URI in custom_properties.placeholder. New methods on Media:
getPlaceholder(): ?string — returns the data URI or nullgetUrlOrPlaceholder(string $conversion = ''): string — returns conversion URL when the file exists, falls back to placeholder, then to the original URL. Useful right after upload when queued conversions have not run yet.getPictureHtml() and getSimpleImgHtml() automatically inject the placeholder as a CSS background-image on the inner <img>. Opt out per call with ['placeholder' => false]. Silent when no placeholder exists.mediaman.placeholder (enabled, width, blur, quality). Default off, matching the package convention of opt-in feature toggles (mirrors responsive_images.auto_generate). Only fires for image/* uploads; failures fall back to null without breaking the upload.docs/recipes.md — seven pluggable patterns for needs that MediaMan deliberately doesn't ship (image optimization, PDF/video thumbnails, SVG rasterization, ZIP downloads, multi-file uploads, string/stream uploads). Each recipe consumes the package's events + custom_properties + PathGenerator to slot cleanly into the existing pipeline.mediaman:publish, MediaUploader::fromRequestdocs/, with a public API reference organized by class/trait. README is now a short index aligned with the package's core concepts.docs/media.md (the entity) is separate from docs/uploads.md (MediaUploader).docs/models.md documents the channel-vs-collection distinction with realistic ordering examples.docs/installation.md consolidates manual ALTER snippets for upgrades through v2.7.0 and v2.8.0.docs/responsive-images.md clarifies that variants are opt-in by default (the enabled vs auto_generate distinction).mediaman:publishOne-shot publisher for the config and migration:
php artisan mediaman:publish
Individual mediaman:publish-config and mediaman:publish-migration remain for selective use.
MediaUploader::fromRequest()Convenience entry point for the most common case — pulling a single file off the current HTTP request:
MediaUploader::fromRequest()->upload(); // default field 'file'
MediaUploader::fromRequest('avatar')->upload(); // custom field
MediaUploader::fromRequest('avatar', $request)->upload(); // explicit Request (tests, jobs)
The request is resolved from the container when not passed. Throws InvalidArgumentException when the field is missing, empty, or contains a multi-file array.
config/mediaman.php reorganizationThe published config is now organized into four labeled sections mirroring the docs: essentials, validation & security defaults, per-feature configuration, customization. Existing published configs are not affected; the new order only appears on a re-publish.
CHANGELOG.md is now in the repo, backfilled with entries for v2.2.0 → v2.10.0 (Keep-a-Changelog format).
Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v2.9.0...v2.10.0
Three new interfaces under Emaia\MediaMan\Generators let you customize where files live and how their URLs/names are produced. Defaults reproduce existing behavior bit-for-bit — no change unless you opt in.
// config/mediaman.php
'generators' => [
'path' => \Emaia\MediaMan\Generators\DefaultPathGenerator::class,
'url' => \Emaia\MediaMan\Generators\DefaultUrlGenerator::class,
'file_namer' => \Emaia\MediaMan\Generators\DefaultFileNamer::class,
],
'url' => [
'version_query' => false, // append ?v={updated_at} for cache busting
'prefix' => null, // CDN/origin prefix (e.g. https://cdn.example.com)
],
// config/mediaman.php
'url' => [
'version_query' => true,
'prefix' => 'https://cdn.example.com',
],
$media->getUrl(); // https://cdn.example.com/1-hash/photo.jpg?v=1718625600
The prefix is applied correctly to both relative storage URLs (typical for local disks) and absolute storage URLs (typical for S3): for absolute URLs, the scheme+host are stripped and the path is reattached under the prefix. Temporary signed URLs are not prefixed or version-tagged (signatures already cover expiration).
Bind any interface in a service provider to swap the implementation:
use Emaia\MediaMan\Generators\PathGenerator;
use Emaia\MediaMan\Models\Media;
$this->app->bind(PathGenerator::class, function () {
return new class implements PathGenerator {
public function getDirectory(Media $media): string
{
return 'tenants/'.tenant_id().'/'.$media->getKey();
}
public function getPathForConversion(Media $media, string $conversion): string
{
return $this->getDirectory($media).'/conversions/'.$conversion;
}
public function getPathForResponsive(Media $media): string
{
return $this->getDirectory($media).'/responsive';
}
};
});
FileNamer exposes getBaseName, getConversionFileName, and getResponsiveFileName for filename customization (e.g., adding -thumb suffixes or ULID-based names).
Media::getDirectory, getPath, getPathWithCorrectExtension, getUrl, getTemporaryUrlMediaUploader::sanitizeFileNameImageManipulator::getConversionPathWithExtensionResponsiveImageGenerator::generateSingleResponsiveImage, clearResponsiveImagesFull Changelog: https://github.com/emaia/laravel-mediaman/compare/v2.8.0...v2.9.0
mediaman_mediables gains an order_column for per-attachment ordering. attachMedia() and syncMedia() accept an optional position; rows are returned with NULLS LAST semantics.
$post->attachMedia($m1); // auto-sequential (0)
$post->attachMedia($m2); // 1
$post->attachMedia($m3, 'gallery', [], 10); // explicit 10
$post->attachMedia([$m4, $m5], 'gallery', [], 20); // batch: 20, 21
$post->setMediaOrder([$m3->id, $m1->id, $m2->id]); // batch reorder in a DB transaction
setMediaOrder() throws InvalidArgumentException if any id isn't attached in the given channel.
public function registerMediaChannels(): void
{
$this->addMediaChannel('avatar')
->useFallbackUrl('/img/default-avatar.png')
->useFallbackUrl('/img/avatar-thumb.png', 'thumb')
->useFallbackPath(public_path('img/default-avatar.png'));
}
getFirstMediaUrl, getFirstMediaUrlWithFallback, getFirstMediaPath, and the matching getLastMedia* helpers return the configured fallback when the channel has no media. Per-conversion fallbacks override the channel default.
Media::copy() and Media::attachTo()// Clone Media record + primary file + conversions + responsive variants
$copy = $media->copy($otherPost, 'featured');
// Re-attach the same Media to another model (no file ops)
$media->attachTo($otherPost, 'gallery');
copy() rolls back the DB record if any file copy fails, streams across disks. Targets that don't use HasMedia throw Emaia\MediaMan\Exceptions\InvalidCopyTarget.
HasMedia::syncMedia 5th parameter changed from bool $preserveOrder to ?int $startOrder. Existing callers passing booleans will type-error.media() and getMedia() now order by order_column with NULLS LAST. Pivot rows pre-dating this release have NULL and sort to the end. Code relying on insertion order may see different orderings.HasMedia::addMediaChannel() is now public (was protected). PHP fatal-errors if a subclass declared an override as protected — fix by widening to public.Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v2.7.0...v2.8.0
Collections gain three new behaviors: MIME type restriction, item count limits with auto-prune, and fluent configuration setters.
$collection = MediaCollection::create(['name' => 'avatars']);
$collection->singleFile()->save(); // keep only the latest
$collection->onlyKeepLatest(5)->save(); // keep N items
$collection->acceptsMimeTypes(['image/png'])->save(); // single type
$collection->acceptsMimeTypes(['image/*'])->save(); // wildcard
Uploads or direct attaches of an unmatched MIME throw Emaia\MediaMan\Exceptions\MediaNotAcceptedByCollection. An empty array or null means "accept anything".
Validation fires on both paths:
// On upload
MediaUploader::source($file)->useCollection('avatars')->upload();
// On direct attach
$collection->attachMedia($existingMedia);
When a new media pushes the collection above max_items, the oldest (by created_at, with id as tiebreaker) is detached. The Media record itself is never deleted — it may belong to other models or collections.
mediaman_collectionsmax_items (int, nullable)allowed_mime_types (json, nullable)fallback_url (string, nullable) — reserved for use in v2.8.0fallback_path (string, nullable) — reserved for use in v2.8.0Media::collections() had its BelongsToMany foreign/related pivot keys swapped — $media->collections now correctly returns the collections the media belongs to. Code that relied on the old (broken) behavior may see different results.Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v2.6.0...v2.7.0
The Media model gains a set of consumer-facing helpers and now implements Laravel's Attachable contract.
return $media->toResponse(); // download (StreamedResponse)
return $media->toInlineResponse(); // inline (browser displays in-tab)
$stream = $media->getStream(); // raw stream resource — caller closes
All accept an optional conversion name ($media->toResponse('thumb')).
Media implements Illuminate\Contracts\Mail\Attachable:
return $this->view('emails.welcome')->attach(Media::find(1));
// Or with an explicit conversion
return $this->view('emails.welcome')->attach($media->mailAttachment('thumb'));
use Emaia\MediaMan\Exceptions\TemporaryUrlNotSupported;
try {
$url = $media->getTemporaryUrl(now()->addHour());
} catch (TemporaryUrlNotSupported $e) {
// disk has no temporary URL support — fall back to a controller route
}
Default expiration via temporary_url.default_lifetime_minutes config (5 min).
getLast* helpersComplete surface mirroring getFirst*:
$post->getLastMedia();
$post->getLastMediaUrl();
$post->getLastMediaUrl('featured-image', 'thumb');
$post->getLastMediaUrlWithFallback('featured-image', 'thumb');
$post->getLastMediaConversionUrl('featured-image', 'thumb');
$post->hasLastMediaConversion('featured-image', 'thumb');
README backfills the Security section (extensions blocking, SSRF guard, mediaman:clean) — catching up doc debt for v2.2 / v2.3 / v2.4.
Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v2.5.0...v2.6.0
MediaUploader gains three new entry points beyond source($file).
MediaUploader::fromDisk(path, disk)Import a file from any configured Laravel disk. The source file is preserved.
$media = MediaUploader::fromDisk('uploads/photo.jpg', 'public')->upload();
All fluent options (useName, useCollection, useDisk, etc.) still apply.
MediaUploader::fromBase64(data, filename, ?name)Accepts raw base64 or data URIs. Payload size is validated before decoding.
$media = MediaUploader::fromBase64(base64_encode($bytes), 'photo.jpg')->upload();
$media = MediaUploader::fromBase64('data:image/png;base64,iVBORw0...', 'photo.png')->upload();
Configure the pre-decode limit:
'base64' => [
'max_size_bytes' => 50 * 1024 * 1024, // default
],
Oversized payloads throw FileSizeExceeded; malformed data throws InvalidBase64Data.
MediaUploader::fromUrl(url)Downloads remote files with full SSRF protection.
$media = MediaUploader::fromUrl('https://example.com/photo.jpg')->upload();
Defense layers: UrlGuard validation → CURLOPT_RESOLVE pinning (mitigates DNS rebinding, fulfilling the 2.3 promise) → HEAD Content-Length pre-check → in-stream size guard → post-download verification.
Requires ext-curl (declared in composer.json). Uses the url_sources config block introduced in 2.3.
fromUrl dispatches through Emaia\MediaMan\Downloaders\Downloader, bound by default to HttpDownloader. Swap it via the container for custom HTTP stacks or test mocks.
Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v2.4.0...v2.5.0
mediaman:clean artisan commandDetect orphan files on disk and Media records pointing to missing files.
# Dry-run (default) — only reports
php artisan mediaman:clean
# Actually delete orphan files on disk
php artisan mediaman:clean --force
# Scope to a specific disk
php artisan mediaman:clean --disk=s3
The command reports two kinds of orphans:
--forceDetection works at the top-level media directory level. Stale conversion or responsive variants within a valid media directory are not flagged in this release.
0 — completed successfully1 — disk not configuredFull Changelog: https://github.com/emaia/laravel-mediaman/compare/v2.3.0...v2.4.0
MediaMan ships a standalone UrlGuard utility that validates remote URLs against Server-Side Request Forgery (SSRF) before they're fetched. This release introduces the guard and prepares the url_sources configuration block for the upcoming MediaUploader::fromUrl() in 2.5.
use Emaia\MediaMan\Support\UrlGuard;
use Emaia\MediaMan\Exceptions\UrlNotAllowed;
try {
UrlGuard::check('https://example.com/file.jpg'); // OK
UrlGuard::check('http://169.254.169.254/'); // throws UrlNotAllowed (AWS metadata)
UrlGuard::check('http://localhost/admin'); // throws UrlNotAllowed
UrlGuard::check('http://[::1]/'); // throws UrlNotAllowed
UrlGuard::check('ftp://example.com/'); // throws UrlNotAllowed (scheme)
} catch (UrlNotAllowed $e) {
// handle blocked URL
}
http and https are allowed0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16 (AWS/GCP metadata), 172.16.0.0/12, 192.168.0.0/16, 255.255.255.255 broadcast::, ::1, fc00::/7 (ULA), fe80::/10 (link-local), ::ffff:x.x.x.x (IPv4-mapped — delegates to IPv4 check), 2002::/16 (6to4), 2001::/32 (Teredo)// config/mediaman.php
'url_sources' => [
'allow_private_hosts' => false, // set to true to allow private/loopback
'timeout_seconds' => 30,
'max_size_bytes' => 100 * 1024 * 1024,
'verify_ssl' => true,
'user_agent' => 'MediaMan/2.x',
],
For apps that need to download from internal services:
'url_sources' => [
'allow_private_hosts' => true,
],
The guard is not yet wired into uploads — it's a standalone utility. The upcoming 2.5 release will consume it in MediaUploader::fromUrl() and pair it with CURLOPT_RESOLVE pinning to mitigate DNS rebinding between check time and fetch time.
Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v2.2.0...v2.3.0
MediaMan now rejects uploads with extensions commonly used to execute server-side code, before any database write or file storage occurs.
php, phtml, phar, shtml, htaccess, cgi, pl, asp, aspx, jsp, jspx
// Throws Emaia\MediaMan\Exceptions\DisallowedExtension
MediaUploader::source($request->file('upload'))->upload();
The check runs against the sanitized filename, so double-extension attempts like legit.php.jpg are defused (the inner .php is stripped by the sanitizer before validation).
Publish the config and override the defaults:
// config/mediaman.php
'disallowed_extensions' => [
'php', 'phtml', 'phar', // your list
],
// config/mediaman.php
'block_disallowed_extensions' => false,
Uploads with disallowed extensions now throw Emaia\MediaMan\Exceptions\DisallowedExtension. Existing apps that need to accept these extensions must set block_disallowed_extensions => false or override disallowed_extensions in the published config.
Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v2.1.0...v2.2.0
MediaUploader::maxFileSize(int $bytes) and mediaman.max_file_size config (env MEDIAMAN_MAX_FILE_SIZE). 0 = unlimited.FileSizeExceeded exception thrown when upload exceeds the limit.MediaCollection::findByName() and Media::findByName() are now static methods (previously Eloquent scopes). Chained usage like
MediaCollection::with('media')->findByName('x') no longer works — use MediaCollection::findByName('x') (lazy-loads relations).WidthCalculator interface gained calculateWidthsFromBinary(string): Collection. Custom implementations must add this method.findByName('non-existent') now returns null instead of leaking a Builder (visible only when called from Media::fetchCollections).Media::fetchCollections / MediaCollection::fetchMedia no longer throw TypeError when a BaseCollection contains integer ids.HasMedia::syncMedia now uses bulk attach — N items = 1 INSERT instead of N.sys_get_temp_dir. Bytes are read once and reused.Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v2.0.0...v2.1.0
How can I help you explore Laravel packages today?