behzadsp/eloquent-dynamic-photos
A Laravel package that attaches dynamic photos to Eloquent models, generating and serving image variants on the fly. Define custom sizes and conversions, cache results, and keep model media handling simple and configurable.
Build vs. buy: For models that own one or more image columns, this is a "buy" that removes a recurring class of self-inflicted bugs (orphaned files, path traversal, stale conversions, EXIF-rotated mobile uploads). v2.0.1 ships a real test suite (167 tests), a five-cell PHP 8.3–8.5 × Laravel 12/13 CI matrix, phpstan/larastan at max with zero suppressions, and honest support metadata — that is the maintenance evidence you want before adopting a small-vendor package, and it's stronger than most comparable packages offer.
Feature unlocks you can now put on a roadmap:
photoDefinitions() replaces seven per-model override methods with a per-field declaration (PhotoDefinition, Conversion, Fit). Two photo columns on one model can have independent disks, directories, formats, and conversions — avatar-on-S3 + hero-on-local is now a config line, not a fork.regeneratePhotoConversions($field) backfills rows stored before a conversion existed, idempotently — so "add a 400px card thumbnail" becomes a shippable ticket instead of a data-migration project.ProcessConversionsJob, with queue_conversions / queue.connection / queue.name) keeps the primary write synchronous and defers the expensive derivatives. This is a direct lever on upload latency and on request-timeout incidents.deleted hook, soft-delete aware (files survive deleted_at, removed on forceDelete()), gated on transaction commit before touching disk, and globally disablable via delete_files_on_model_delete. That turns "we have X TB of orphaned files" from an unbounded liability into a config decision.max_file_size, max_width/max_height (downscale, never reject, never upscale), and a max_pixels pre-decode budget that accounts for frame count in GIF/WebP. The measured result — a 1.4 MB 100-page LZW TIFF going from ~3 GB allocated to 0 MB under imagick — is a DoS/memory-ceiling control, not a nicety.auto_orient bakes EXIF rotation into pixels before encoding (ext-exif; no-op without it). Sideways user avatars are a support-ticket generator you can delete from the backlog.deletePhoto($field) clears the column and the files; deletePhotoFiles($field) deletes files only. Distinct intent, distinct method — no more "we deleted the file and left the column dangling."StorageFailedException on Storage::put() returning false (Laravel's default filesystem config does this instead of throwing) for both primary and every conversion, so silent storage failures surface in your error tracker instead of as 404s in a week.Upgrade urgency if you are on v1: treat this as a security and correctness mandate, not a nice-to-have. v1 defects fixed in v2, each with a named regression test:
/ or .. could write outside the intended directory.updatePhoto() deleted the old file before writing the new one, so any later failure left the DB pointing at a file that no longer existed._url accessor stripped every occurrence of the _url substring, and fired for any attribute pair ending in _url — a model with real website / website_url columns silently returned Storage::url($website).getPhotoFullPath() used Storage::path(), which on S3 silently returns a locally-prefixed key that is not a filesystem path.getEloquentPhotoTimestampFormat() called a method that did not exist — fatal for anyone who followed the README.str() requires Laravel 9 while composer.json claimed ^7.0|^8.0), and intervention/image ^2.7 was EOL.Migration cost to budget: v2 is a rewrite of the model-facing API. The seven override methods are gone, config keys are renamed (root_directory → directory, slug_limit → name_limit), the config file moved to config/eloquent_photo.php under the eloquent-photo-config tag, and photoFullPath() / photoPath() / photoUrl() now take a field name rather than a raw path. Follow UPGRADE.md; scope this as a discrete ticket with call-site greps, not a drive-by.
Release hygiene flag for engineering: install v2.0.1, not v2.0.0. Packagist pinned v2.0.0 to the commit before Laravel 11 was dropped and cannot be moved off an already-ingested tag, so v2.0.0 is not installable against the intended matrix. Require ^2.0.1.
Adopt when:
regeneratePhotoConversions) rather than a bespoke script.Look elsewhere when:
laravel/framework advisories have no 11.x fix (landing in 12.60.0/12.61.1/13.10.0), and Composer 2.10+ blocks the whole 11.x line by default. If you cannot move Laravel, this package cannot help you, and a "supported on paper" ^11.0 would not have installed anyway.intervention/image v2 or v3 by another package. v2 jumps straight to ^4.2; v3 is not in the matrix. Resolve that conflict before you plan the upgrade.spatie/laravel-medialibrary is the right shape.srcset generation, or on-the-fly URL transforms. Pair a CDN/transform service instead; this package generates derivatives at write time only.Cost note to carry into the decision: default quality rose from 50 to 75. New uploads are larger and visibly better; existing stored files are unaffected. If storage or CDN egress is a watched metric, model the delta before you flip it, or override the config deliberately.
To executives — one paragraph: We are standardising image handling on a package that was just rewritten, and the rewrite is primarily a risk-reduction event. It closes a path-traversal defect where a user-supplied filename could write outside the intended directory, fixes an upload sequence that could leave records pointing at deleted files, and caps memory exposure from malicious or malformed uploads — a crafted 1.4 MB TIFF that previously allocated ~3 GB now allocates effectively nothing. It also eliminates orphaned files on delete (a direct storage-cost line), makes adding a new thumbnail size a config change plus an idempotent backfill rather than a migration project, and lets us move derivative generation to the queue so upload latency and request timeouts improve. The support matrix is now honest — PHP 8.3+, Laravel 12/13 — and deliberately excludes Laravel 11 because it is past its security window with unfixed framework advisories. Cost: a scoped migration ticket, since the model-facing API changed completely, and slightly larger files from a better default quality setting.
To engineering — the short version:
photoDefinitions(): array<string, PhotoDefinition> declaration using PhotoDefinition / Conversion / Fit. Per-field disks, directories, formats, and conversions; two photo columns on one model can differ freely.^2.0.1 — v2.0.0 is uninstallable (Packagist pinned it to a pre-Laravel-11-drop commit and won't move an ingested tag). Read UPGRADE.md; config keys renamed (root_directory→directory, slug_limit→name_limit), config moved to config/eloquent_photo.php (tag eloquent-photo-config), and photoFullPath()/photoPath()/photoUrl() now take a field name.Str::slug() on all filename construction, suffix-only _url accessor gated on declared fields, StorageFailedException when a disk rejects a write, and no pointless transaction widening a DB lock around I/O.delete_files_on_model_delete. Conversions can be deferred via queue_conversions / queue.connection / queue.name (ProcessConversionsJob); the primary is always synchronous.max_file_size, downscale-only max_width/max_height, and a frame-count-aware max_pixels pre-decode budget. auto_orient needs ext-exif and no-ops without it. Driver (gd/imagick) is runtime-selected via the driver key through Intervention v4's ImageManager::usingDriver() — no hard ext-gd dependency.src/, 167 tests, composer test / test:coverage / lint / analyse, CI across PHP 8.3–8.5 × Laravel 12–13. Match that in anything we write alongside it.How can I help you explore Laravel packages today?