Weave Code
Code Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Mediaman Laravel Package

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.

View on GitHub
Deep Wiki
Context7
v3.2.0

Adds immutable-safe conversion generation paths with signed manifests, lifecycle coordination, and retention-aware pruning.

Added

  • Conversions can opt into immutable-safe per-conversion ULID paths with MEDIAMAN_CONVERSION_VERSIONING=generation, persisted active metadata, independent retention, and in-progress timeout settings.
  • Added mediaman:prune-conversion-generations, a dry-run-first command for inactive conversion generations.

Changed

  • Conversion completion now follows successful manifest publication, queued --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.

Fixed

  • Conversion reads remain pinned to persisted active paths and disks after registration/configuration changes; generation, clear, pruning, copy, delete, and path rotation now coordinate around versioned conversion state.
  • Conversion writes treat filesystem false results as failures, merge manifests through a fresh locked row, and preserve the previous active generation until replacement publication succeeds.
  • Invalid conversion manifest signatures now emit warnings and are reported by doctor/stats instead of degrading silently. 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

v3.1.0

Adds immutable-safe responsive generation paths with atomic publication, lifecycle coordination, and retention-aware pruning.

Added

  • Responsive images can opt into immutable-safe generation paths with 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.
  • Added 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.
  • Responsive generation returns a structured ResponsiveGenerationResult describing no-op, complete, or partial publication outcomes. Hard failures continue to throw so queued retries remain unchanged.

Changed

  • 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.
  • Responsive and conversion jobs consistently use the connection configured by 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.

Fixed

  • Responsive manifest publication now merges into a fresh locked media row instead of allowing stale queued models to overwrite unrelated custom properties.
  • Responsive writes now treat a filesystem 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.
  • Clearing responsive images now unpublishes metadata and increments a generation epoch before storage cleanup, preventing already-running generations from republishing after clear.
  • Clear now keeps a retryable tombstone until storage cleanup succeeds; generation refuses publication while clear is active, and pruning claims a generation in the database before deleting it.
  • Generate, clear, prune, stats, doctor, and path-rotation flows resolve the configured media model; generation backfills stream records in bounded pages and honor mediaman.queue.
  • Responsive generation disk history is retained so clear, delete, doctor, and pruning can still discover old generations after multiple disk changes.
  • Clear tombstones are signed to their media record, while transactional hard-delete and path-rotation claims prevent stale workers from publishing or moving responsive files during destructive lifecycle operations.
  • Clear tombstones accept Laravel's APP_PREVIOUS_KEYS key ring during APP_KEY rotation, and responsive metadata is validated before path derivation.
  • Force-delete coordinates against fresh lifecycle state without firing model update observers; its row lock is released after the database delete and before storage cleanup begins.
  • Responsive doctor/stats counts remain database-driven, pruning isolates malformed markers per generation, and dry-run lifecycle commands no longer acquire write claims.

Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v3.0.2...v3.1.0

v3.0.2

Restores a working public-disk default for fresh Laravel installations and keeps queued conversion URLs aligned with their canonical output extension.

Fixed

  • New uploads now default to Laravel's 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.
  • Automatic conversions now derive their predicted extension from the source MIME when registry, conversion-name, and existing-file detection do not resolve it. URLs generated before queued conversions exist now match the canonical file eventually written, including .jfif uploads encoded as JPEG .jpg files.

Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v3.0.1...v3.0.2

v3.0.1

3.0.1 — Maintenance patch

Small follow-up to v3.0.0: documentation sweep and a friendlier dependency story for MediaUploader::fromUrl().

ext-curl is now optional

The 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().

Documentation sweep

  • Core concepts in the README are now plural (Channels, Conversions) to match Responsive Images and the docs table
  • The docs table entry "Models & associations" is renamed to Models & Channels, with the page H1 and all cross-refs updated
  • Stale v2 upgrade notes moved out of docs/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 images

Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v3.0.0...v3.0.1

v3.0.0

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.

MediaResolver consolidation

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: getDirectorydirectory, getUrlurl, etc. DefaultMediaResolver preserves v2 behavior bit-for-bit — extend it and override only the methods you actually need.

Channel-level validation rules

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.

Per-variant disk for conversions and responsive images

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.

HEIC as a responsive format + per-format encode isolation

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.

Per-format responsive quality

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).

fromStream + fromString

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.

URL versioning enum

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.

ConversionFailed event + isolated conversion runs

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.

Atomic upload + MediaFileWriteFailed

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.

Security pass

A 15-commit pass landing the audit findings. Highlights — full list in UPGRADING.md:

  • SVG uploads disabled by default. Opt in with 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).
  • Extension blocklist expanded from 12 to 24 entries — shell-script + Windows-executable defense-in-depth.
  • 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.
  • Pipeline-level safety: 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.

mediaman:doctor: format probe + FFI/SAPI hints

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

Migrating from v2.18

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

v2.18.0

Added

  • 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.
v2.17.2

Fixed

  • 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.
v2.17.1

Fixed

  • File deletion is now soft-delete aware. The 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.
v2.17.0

Added

  • 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.

Fixed

  • Performance regression in 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).
  • Pre-existing phpstan error in Media::getTemporaryUrl() resolved — removed redundant method_exists() guard in favor of direct providesTemporaryUrls() call.

Changed

  • 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.
  • Console command class names dropped the redundant Mediaman prefix: MediamanCleanCommandCleanCommand, MediamanDoctorCommandDoctorCommand, MediamanPublishCommandPublishCommand, MediamanPublishConfigCommandPublishConfigCommand, MediamanPublishMigrationCommandPublishMigrationCommand, MediamanRotatePathsCommandRotatePathsCommand. 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.

Removed

  • The 5 predefined 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.
v2.16.0

Added

  • 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).
v2.15.0

Added

  • Support for the vips driver from Intervention Image 4. Auto-detection now prefers vipsimagickgd (previously imagickgd); 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.
v2.14.0

Added

  • Two lightweight 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}.
    • See Responsive images → Choosing a placeholder generator for the trade-off table.
v2.13.0

Added

  • Pluggable LQIP via 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).
  • Image meta (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.

Changed

  • 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.
  • LQIP payload is an SVG wrapper instead of a raw JPEG. The SVG 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 dimensionsimage_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.
  • Placeholder config keys for the default generator moved to a 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.

Removed

  • Inline style="background-image:url('data:image/jpeg;…')" injection in getPictureHtml() / getSimpleImgHtml().

Notes

  • getSimpleImgHtml() is unchanged and remains the explicit escape hatch when callers need a bare <img> (email templates, etc.).

Upgrading

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.

v2.12.0

Added

  • 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.
v2.11.0

Changed

  • 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.

Tooling

  • CI now runs phpstan and pint --test jobs in parallel with the test matrix. Previously only pest ran on PRs.
  • New .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.
  • New .github/PULL_REQUEST_TEMPLATE.md prompts contributors to update the CHANGELOG and relevant docs.

Added

  • LQIP placeholder is now a native (opt-in) feature. Enable via 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 null
    • getUrlOrPlaceholder(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.
    • Configurable via 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.
v2.10.0

Docs reorganization, mediaman:publish, MediaUploader::fromRequest

Documentation highlights

  • The README has been split into 12 topic-focused files under docs/, 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).
  • Every page has a table of contents at the top.

mediaman:publish

One-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 reorganization

The 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

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

v2.9.0

Pluggable PathGenerator, UrlGenerator, and FileNamer

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.

Configuration

// 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)
],

URL prefix and version query

// 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).

Custom generators

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).

Wired into

  • Media::getDirectory, getPath, getPathWithCorrectExtension, getUrl, getTemporaryUrl
  • MediaUploader::sanitizeFileName
  • ImageManipulator::getConversionPathWithExtension
  • ResponsiveImageGenerator::generateSingleResponsiveImage, clearResponsiveImages

Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v2.8.0...v2.9.0

v2.8.0

Ordering, channel fallbacks, copy, and attachTo

Ordering

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.

Channel fallbacks

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.

Breaking behavior

  • 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

v2.7.0

MediaCollection validation, fluent setters, and auto-prune

Collections gain three new behaviors: MIME type restriction, item count limits with auto-prune, and fluent configuration setters.

Fluent 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

MIME type validation

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);

Auto-prune

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.

New columns on mediaman_collections

  • max_items (int, nullable)
  • allowed_mime_types (json, nullable)
  • fallback_url (string, nullable) — reserved for use in v2.8.0
  • fallback_path (string, nullable) — reserved for use in v2.8.0

Fixes

  • Media::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

v2.6.0

HTTP responses, mail, temporary URLs, getLast* helpers

The Media model gains a set of consumer-facing helpers and now implements Laravel's Attachable contract.

HTTP responses

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')).

Mail attachments

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'));

Temporary URLs

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* helpers

Complete 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');

Documentation

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

v2.5.0

Multi-source uploads

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.

Custom downloader

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

v2.4.0

mediaman:clean artisan command

Detect orphan files on disk and Media records pointing to missing files.

Usage

# 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

Detection

The command reports two kinds of orphans:

  • Files on disk without a matching Media record → deletable with --force
  • Media records pointing to missing files → reported only; never auto-deleted (cascade safety: a Media record may be attached to models or collections, so removal is left to the operator)

Detection works at the top-level media directory level. Stale conversion or responsive variants within a valid media directory are not flagged in this release.

Exit codes

  • 0 — completed successfully
  • 1 — disk not configured

Full Changelog: https://github.com/emaia/laravel-mediaman/compare/v2.3.0...v2.4.0

v2.3.0

SSRF protection for remote URL sources

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.

Usage

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
}

What's blocked

  • Schemes: only http and https are allowed
  • IPv4: 0.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
  • IPv6: ::, ::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)
  • DNS resolution: both A and AAAA records are checked; any private IP rejects the URL

Configuration

// 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',
],

Opting out

For apps that need to download from internal services:

'url_sources' => [
    'allow_private_hosts' => true,
],

What's next

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

v2.2.0

Block dangerous file extensions on upload

MediaMan now rejects uploads with extensions commonly used to execute server-side code, before any database write or file storage occurs.

Default block list

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).

Customizing the list

Publish the config and override the defaults:

// config/mediaman.php
'disallowed_extensions' => [
    'php', 'phtml', 'phar', // your list
],

Opting out

// config/mediaman.php
'block_disallowed_extensions' => false,

Breaking behavior

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

v2.1.0

What's Changed

Added

  • 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.

Changed (potentially breaking)

  • 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.

Fixed

  • 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.

Performance

  • HasMedia::syncMedia now uses bulk attach — N items = 1 INSERT instead of N.
  • Responsive image pipeline (generator + dimension calculation) no longer writes to 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

v1.5.3
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
calmfox/watch-sylius
damienfern/grpc-symfony-bundle
atoolo/index-bundle
atoolo/genai-bundle
coprotoai/laravel-ticket
davidjln/llm-carbon-bundle
cryonighter/valid-request-bundle
coolms/taxonomy-bundle
coolms/field-bundle
articulate-orm/symfony
aaix/laravel-tall-architect
ephoto/akeneo-connector
emmanuelballery/eb-plantumlbundle
emielburgman/symfony-visitor-beacon
emielburgman/symfony-visit-storage
emielburgman/symfony-security-headers
emielburgman/symfony-log-viewer
emarref/xdebug-bundle
emarref/pubnub-bundle
elriseio/finance-money-bundle