Professional Laravel image upload, processing and multi-disk management package.
- WebP & AVIF conversion — Auto-convert any uploaded image to WebP or AVIF on the fly
- Multi-size variants — Generate thumbnail, medium, large and custom size presets in one pass
- Multi-disk support — Switch between
local,s3,ftp,sftp, GCS and any Flysystem driver - Duplicate detection — SHA-256 hash-based deduplication with
reuse,throworallowmodes - Watermarking — Apply logo or text watermarks with configurable position and opacity
- LQIP placeholders — Generate base64 blur placeholders for smooth lazy-loading transitions
- EXIF stripping — Remove GPS coordinates and device info to protect user privacy
- CDN integration — Built-in URL generators for Imgix, Cloudinary, ImageKit and Cloudflare Images
- Event system —
ImageUploaded,ImageProcessed,ImageDeletedevents for reactive workflows - Queue support — Dispatch image processing to a background queue for fast HTTP responses
- HasImages trait — Drop into any Eloquent model for instant upload/retrieve/delete support
- Artisan commands —
imageman:regenerate,imageman:clean,imageman:convert - Blade directives —
@image,@responsiveImage,@lazyImage - API Resource — Ready-made
ImageResourcefor JSON API responses - Filament v3 — Form component and table column for the Filament admin panel
- Laravel Nova — Custom field for the Nova admin panel
- Fluent API — Chainable builder that reads like plain English
| Dependency | Version |
|---|---|
| PHP | ^8.1 |
| Laravel | ^10.0 | ^11.0 |
| Intervention Image | ^3.0 |
Optional (for admin panel integrations):
filament/filament^3.0laravel/nova^4.0
1. Install via Composer:
composer require ibrahim-kaya/imageman2. Publish the config file:
php artisan vendor:publish --tag=imageman-config3. Publish and run migrations:
php artisan vendor:publish --tag=imageman-migrations
php artisan migrateThe imageman_images table will be created in your database.
After publishing, edit config/imageman.php. Every option is documented with an inline comment in the file. Key settings:
| Key | Default | Description |
|---|---|---|
disk |
local |
Default filesystem disk (env: IMAGEMAN_DISK) |
path |
images |
Storage directory prefix (env: IMAGEMAN_PATH) |
format |
webp |
Output format: webp, avif, jpeg, original |
webp_quality |
80 |
WebP encoding quality (1–100) |
avif_quality |
70 |
AVIF encoding quality (1–100) |
default_sizes |
['thumbnail','medium'] |
Size presets generated for every upload |
detect_duplicates |
true |
Enable SHA-256 duplicate detection |
on_duplicate |
reuse |
reuse / throw / allow |
queue |
false |
Process images in a background queue |
url_generator |
default |
default, imgix, cloudinary, imagekit, cloudflare |
generate_lqip |
true |
Generate blur placeholder data URIs |
strip_exif |
true |
Strip EXIF metadata for privacy |
use IbrahimKaya\ImageMan\ImageManFacade as ImageMan;
// From an HTTP request field
$image = ImageMan::upload($request->file('photo'))->save();
// From a remote URL
$image = ImageMan::uploadFromUrl('https://example.com/photo.jpg')->save();
echo $image->url('medium'); // Public URL for the medium variant
echo $image->url('thumbnail'); // Public URL for the thumbnail
echo $image->url(); // Main image URL$image = ImageMan::find(1); // Returns ?Image
$image = ImageMan::get(1); // Returns Image or throws ImageNotFoundException
ImageMan::destroy(1); // Deletes DB record + disk files
$image->delete(); // Same, called on the model instanceThe uploadFromUrl() method downloads the remote image and passes it through the same processing pipeline as a regular upload (WebP conversion, resizing, watermarking, etc.):
// Basic
$image = ImageMan::uploadFromUrl('https://example.com/photo.jpg')->save();
// Full fluent chain — all options available
$image = ImageMan::uploadFromUrl('https://cdn.example.com/banner.png', timeoutSeconds: 60)
->disk('s3')
->collection('remote')
->sizes(['thumbnail', 'medium', 'large'])
->format('avif')
->watermark()
->meta(['alt' => 'Remote banner'])
->save();
// Via HasImages trait — just pass the URL string instead of an UploadedFile
$post->uploadImage('https://example.com/photo.jpg', 'gallery');
$user->uploadImage('https://example.com/avatar.png', 'avatars', ['disk' => 's3', 'timeout' => 45]);Error handling:
use IbrahimKaya\ImageMan\Exceptions\UrlFetchException;
try {
$image = ImageMan::uploadFromUrl($url)->save();
} catch (UrlFetchException $e) {
// URL not reachable, non-2xx response, or response is not an image
Log::error('Remote image download failed', ['url' => $url, 'reason' => $e->getMessage()]);
}All methods return $this (except save() which returns an Image model).
| Method | Description |
|---|---|
->disk('s3') |
Override the storage disk |
->collection('gallery') |
Set the collection name |
->sizes(['thumbnail','large']) |
Choose which size presets to generate |
->withOriginal() |
Also keep the unmodified original file |
->for($model) |
Associate with an Eloquent model |
->meta(['alt' => '…']) |
Attach metadata (alt text, title, etc.) |
->format('avif') |
Override output format for this upload |
->filename('my-photo') |
Custom filename stem (slugified, no extension needed) |
->inDirectory('products') |
Store inside a custom subdirectory |
->noUuid() |
Omit UUID folder for a deterministic, stable path |
->watermark() |
Enable watermark (overrides config) |
->noWatermark() |
Disable watermark for this upload |
->watermarkImage($path) |
Use a specific image file as watermark |
->watermarkText('© 2024') |
Use a text string as watermark |
->withLqip() |
Enable LQIP generation |
->withoutLqip() |
Disable LQIP generation |
->replaceExisting() |
Delete old images in the collection after saving |
->keepExisting() |
Keep old images even if collection is singleton |
->maxSize(2048) |
Max file size in KB |
->minWidth(400) |
Minimum width in pixels |
->maxWidth(2000) |
Maximum width in pixels |
->aspectRatio('16/9') |
Enforce aspect ratio |
->save() |
Execute and return Image model |
Define named presets in config/imageman.php:
'sizes' => [
'thumbnail' => ['width' => 150, 'height' => 150, 'fit' => 'cover'],
'medium' => ['width' => 800, 'height' => 600, 'fit' => 'contain'],
'large' => ['width' => 1920, 'height' => 1080, 'fit' => 'contain'],
'hero' => ['width' => 2560, 'height' => 1440, 'fit' => 'cover'],
],Retrieve by name:
$image->url('thumbnail'); // Thumbnail URL
$image->url('hero'); // Hero URL
$image->path('medium'); // Storage path
$image->variants(); // ['thumbnail' => ['path' => …, 'width' => 150, …], …]
$image->srcset(); // "url 150w, url 800w, url 1920w"Switch disk per upload — no config change needed:
// Upload to S3
$image = ImageMan::upload($file)->disk('s3')->save();
// Upload to FTP
$image = ImageMan::upload($file)->disk('ftp')->save();
// Default disk override for the next upload only
ImageMan::disk('s3')->upload($file)->save();Make sure the target disk is configured in config/filesystems.php.
Set a custom filename stem for the stored file. The extension is always determined automatically from the output format — do not include it:
$image = ImageMan::upload($file)
->filename('product-hero')
->save();
// → images/{uuid}/product-hero.webp
// → images/{uuid}/product-hero_thumbnail.webpTurkish characters and spaces are automatically slugified:
->filename('Ürün Fotoğrafı') // → urun-fotografıGroup images into a subdirectory without changing the base path config:
$image = ImageMan::upload($file)
->inDirectory('products/phones')
->filename('iphone-16')
->save();
// → images/products/phones/{uuid}/iphone-16.webpEach directory segment is slugified automatically:
->inDirectory('Ürün Görselleri') // → urun-gorselleriBy default every upload gets its own UUID folder to prevent collisions. Call ->noUuid() to omit it and get a fully stable, predictable URL — ideal for profile photos and cover images:
$image = ImageMan::upload($file)
->inDirectory('users/' . $user->id)
->filename('avatar')
->noUuid()
->save();
// → images/users/42/avatar.webp (always the same URL)Note: When
noUuid()is active, uploading to the same path again silently overwrites the existing file. Combine with->replaceExisting()(or singleton collections) to also clean up the old database record.
inDirectory() |
noUuid() |
filename() |
Result |
|---|---|---|---|
| ✗ | ✗ | ✗ | images/{uuid}/{uuid}.webp |
'products' |
✗ | ✗ | images/products/{uuid}/{uuid}.webp |
'products' |
✗ | 'iphone' |
images/products/{uuid}/iphone.webp |
'users/42' |
✓ | 'avatar' |
images/users/42/avatar.webp |
'products' |
✓ | ✗ | images/products/{uuid}.webp |
Change format globally (in .env or config):
# .env
IMAGEMAN_DISK=s3// config/imageman.php
'format' => 'avif', // Convert everything to AVIFOr per upload:
$image = ImageMan::upload($file)->format('avif')->save();
$image = ImageMan::upload($file)->format('original')->save(); // No conversionSet a default watermark for all uploads in config/imageman.php:
'watermark' => [
'enabled' => true,
'type' => 'image', // 'image' or 'text'
'path' => storage_path('app/watermark.png'),
'text' => null,
'position' => 'bottom-right',
'opacity' => 50,
'padding' => 15,
],Override the watermark image on a single upload without touching the config file:
$image = ImageMan::upload($file)
->watermarkImage(storage_path('app/logo.png'))
->save();
// All options:
$image = ImageMan::upload($file)
->watermarkImage(
path: storage_path('app/logo.png'),
position: 'bottom-right', // top-left | top-center | top-right
// center-left | center | center-right
// bottom-left | bottom-center | bottom-right
opacity: 40, // 0 (invisible) – 100 (solid)
padding: 15, // px gap from edge
)
->save();$image = ImageMan::upload($file)
->watermarkText('© 2024 My Company')
->save();
// All options:
$image = ImageMan::upload($file)
->watermarkText(
text: '© 2024 My Company',
position: 'bottom-center',
opacity: 70,
padding: 12,
)
->save();// Enable for this upload only (uses config path/text)
$image = ImageMan::upload($file)->watermark()->save();
// Disable for this upload only (even if config has enabled: true)
$image = ImageMan::upload($file)->noWatermark()->save();| Method called | Result |
|---|---|
->watermarkImage($path) |
Enables watermark, uses given image |
->watermarkText($text) |
Enables watermark, uses given text |
->watermark() |
Enables watermark, keeps config type/path/text |
->noWatermark() |
Disables watermark for this upload |
| (none) | Falls back entirely to config/imageman.php |
When the same file is uploaded twice, ImageMan checks the SHA-256 hash:
// config/imageman.php
'detect_duplicates' => true,
'on_duplicate' => 'reuse', // 'reuse' | 'throw' | 'allow'| Mode | Behaviour |
|---|---|
reuse |
Returns the existing Image model. No new file stored. |
throw |
Throws DuplicateImageException with a reference to the existing image. |
allow |
Creates a new record regardless. |
Handle the exception:
use IbrahimKaya\ImageMan\Exceptions\DuplicateImageException;
try {
$image = ImageMan::upload($file)->save();
} catch (DuplicateImageException $e) {
$existing = $e->existingImage();
return response()->json(['message' => 'Duplicate', 'image' => $existing]);
}A singleton collection holds exactly one image per model instance. When a new image is uploaded to a singleton collection, all previously stored images in that collection for the same model are automatically deleted after the new image has been successfully saved — so the model is never left without an image during the transition.
List the collection names in config/imageman.php:
'singleton_collections' => ['profile_pic', 'avatar', 'cover'],Any upload to those collections will automatically replace the previous one — no extra method call needed:
// First upload
$user->uploadImage($request->file('photo'), 'profile_pic');
// Second upload — first image is automatically deleted after this saves
$user->uploadImage($request->file('photo'), 'profile_pic');Use the fluent method to opt in on a single upload:
$image = ImageMan::upload($file)
->for($user)
->collection('avatar')
->replaceExisting()
->save();// Explicit replace
$user->uploadImage($file, 'profile_pic', ['replace' => true]);
// Explicit keep (even if profile_pic is in singleton_collections config)
$user->uploadImage($file, 'profile_pic', ['replace' => false]);Call ->keepExisting() to opt out of automatic replacement even when the collection is listed in config:
// profile_pic is in singleton_collections but we want to keep both this time
$image = ImageMan::upload($file)
->for($user)
->collection('profile_pic')
->keepExisting()
->save();| Scenario | Result |
|---|---|
replaceExisting() + model set |
Old images in collection deleted after new save |
keepExisting() |
Old images kept regardless of config |
replaceExisting() + no model |
Silently ignored (no scope to delete within) |
| Different collection on same model | Untouched |
| Same collection on different model instance | Untouched |
Safety guarantee: Old images are only deleted after the new image has been fully written to disk and the database record has been committed. If processing fails before that point, the old image remains intact.
Access the blur placeholder:
$image->lqip(); // "data:image/webp;base64,/9j/4AAQ…"Use in Blade with the @lazyImage directive:
@lazyImage($post->image->id, 'large', ['class' => 'lazyload w-full'])Renders:
<img
src="data:image/webp;base64,…"
data-src="https://…/images/abc.webp"
class="lazyload w-full"
loading="lazy"
alt="…"
>Add lazysizes to your front-end for the blur-to-sharp transition.
Enabled by default. Strips GPS coordinates, device model, and all other EXIF metadata before saving:
// config/imageman.php
'strip_exif' => true,Set global rules in config:
'validation' => [
'max_size' => 10240, // 10 MB
'allowed_mimes' => ['image/jpeg', 'image/png', 'image/webp'],
],Or apply rules fluently per upload:
use IbrahimKaya\ImageMan\Exceptions\ValidationException;
try {
$image = ImageMan::upload($request->file('photo'))
->maxSize(5120) // 5 MB max
->minWidth(400) // At least 400px wide
->maxWidth(4096)
->aspectRatio('16/9') // Must be widescreen
->save();
} catch (ValidationException $e) {
return back()->withErrors($e->errors());
}Add the trait to any Eloquent model:
use IbrahimKaya\ImageMan\Traits\HasImages;
class User extends Model
{
use HasImages;
}
class Post extends Model
{
use HasImages;
}// Upload
$user->uploadImage($request->file('avatar'), 'avatars');
$post->uploadImage($request->file('photo'), 'gallery', ['sizes' => ['thumbnail', 'large']]);
// Retrieve
$user->getImage('avatars'); // → ?Image (most recent)
$post->getImages('gallery'); // → Collection<Image>
$post->getAllImages(); // → Collection grouped by collection name
$user->hasImage('avatars'); // → bool
// Eloquent relationships
$user->images; // All images (morphMany)
$user->image; // Latest default image (morphOne)
// Delete
$post->deleteImages('gallery'); // Delete all gallery images + files
$post->deleteImages('*'); // Delete all collectionsListen to ImageMan events in your EventServiceProvider:
use IbrahimKaya\ImageMan\Events\ImageUploaded;
use IbrahimKaya\ImageMan\Events\ImageProcessed;
use IbrahimKaya\ImageMan\Events\ImageDeleted;
protected $listen = [
ImageUploaded::class => [SendUploadNotification::class],
ImageProcessed::class => [TriggerCdnWarmup::class],
ImageDeleted::class => [CleanupSearch::class],
];Event payloads:
// ImageUploaded
$event->image; // Image model
$event->model; // Associated Eloquent model (or null)
// ImageProcessed
$event->image; // Image model (with variants populated)
$event->variants; // ['thumbnail' => ['path' => …], …]
// ImageDeleted
$event->imageId; // Former primary key
$event->disk; // Disk name
$event->paths; // Array of deleted file pathsEnable background processing to keep upload responses fast:
// config/imageman.php
'queue' => true,
'queue_connection' => 'redis',
'queue_name' => 'images',Start the worker:
php artisan queue:work --queue=imagesWhen queue is enabled, save() inserts the DB record immediately (so you get an ID back) and dispatches ProcessImageJob. Variants are available once the job completes — listen to ImageProcessed to know when they are ready.
Re-run size generation for existing images. Useful after adding a new size preset:
php artisan imageman:regenerate
php artisan imageman:regenerate --size=hero # Only the 'hero' preset
php artisan imageman:regenerate --collection=gallery # Only gallery images
php artisan imageman:regenerate --disk=s3 # Only S3 imagesRemove images with no associated model:
php artisan imageman:clean # With confirmation prompt
php artisan imageman:clean --dry-run # Preview only (no deletions)
php artisan imageman:clean --older-than=30 # Only images older than 30 daysConvert stored images to a new format (e.g. after changing config.format):
php artisan imageman:convert --format=webp
php artisan imageman:convert --format=avif
php artisan imageman:convert --format=webp --dry-run// config/imageman.php
'url_generator' => 'imgix',
'imgix' => [
'domain' => env('IMGIX_DOMAIN'), // e.g. 'mysite.imgix.net'
'sign_key' => env('IMGIX_SIGN_KEY'), // optional
],'url_generator' => 'cloudinary',
'cloudinary' => [
'cloud_name' => env('CLOUDINARY_CLOUD_NAME'),
'api_key' => env('CLOUDINARY_API_KEY'),
'api_secret' => env('CLOUDINARY_API_SECRET'),
],'url_generator' => 'imagekit',
'imagekit' => [
'url_endpoint' => env('IMAGEKIT_URL_ENDPOINT'),
'public_key' => env('IMAGEKIT_PUBLIC_KEY'),
'private_key' => env('IMAGEKIT_PRIVATE_KEY'),
],'url_generator' => 'cloudflare',
'cloudflare' => [
'account_id' => env('CF_IMAGES_ACCOUNT_ID'),
'api_token' => env('CF_IMAGES_API_TOKEN'),
],Implement UrlGeneratorContract and reference your class:
'url_generator' => \App\ImageMan\MyCustomUrlGenerator::class,@image($post->image->id, 'medium', ['class' => 'rounded-xl', 'alt' => 'Post photo'])Renders:
<img src="https://…/abc.webp" width="800" height="600" class="rounded-xl" alt="Post photo" loading="lazy">@responsiveImage($post->image->id, [
'sizes' => '(max-width: 768px) 100vw, 800px',
'class' => 'w-full',
])Renders:
<img
src="https://…/abc_medium.webp"
srcset="https://…/abc_thumbnail.webp 150w, https://…/abc_medium.webp 800w, https://…/abc_large.webp 1920w"
sizes="(max-width: 768px) 100vw, 800px"
width="800" height="600"
class="w-full"
loading="lazy"
alt="…"
>@lazyImage($post->image->id, 'large', ['class' => 'lazyload'])Renders an <img> with src set to the LQIP placeholder and data-src pointing to the full image, ready for lazysizes.
Use in controllers to return a consistent JSON structure:
use IbrahimKaya\ImageMan\Resources\ImageResource;
// Single image
return ImageResource::make($image);
// Collection
return ImageResource::collection(Image::all());Example JSON output:
{
"id": 1,
"url": "https://cdn.example.com/images/abc.webp",
"variants": {
"thumbnail": "https://cdn.example.com/images/abc_thumbnail.webp",
"medium": "https://cdn.example.com/images/abc_medium.webp"
},
"lqip": "data:image/webp;base64,UklGRk…",
"srcset": "https://… 150w, https://… 800w",
"width": 1920,
"height": 1080,
"size": 204800,
"mime_type": "image/webp",
"original_filename": "hero-photo.jpg",
"collection": "gallery",
"disk": "s3",
"meta": { "alt": "Hero photo" },
"created_at": "2024-01-01T00:00:00.000000Z",
"updated_at": "2024-01-01T00:00:00.000000Z"
}Register the plugin in your panel provider:
use IbrahimKaya\ImageMan\Integrations\Filament\FilamentImageManPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugin(FilamentImageManPlugin::make());
}use IbrahimKaya\ImageMan\Integrations\Filament\Forms\ImageManUpload;
ImageManUpload::make('photo')
->collection('avatars')
->disk('s3')
->sizes(['thumbnail', 'medium'])use IbrahimKaya\ImageMan\Integrations\Filament\Tables\ImageManColumn;
ImageManColumn::make('photo')
->collection('avatars')
->size('thumbnail')
->circular()use IbrahimKaya\ImageMan\Integrations\Nova\ImageManField;
public function fields(NovaRequest $request): array
{
return [
ImageManField::make('Photo')
->collection('avatars')
->disk('s3')
->size('medium'),
];
}To serve images stored on a private disk through your Laravel app, enable the optional routes:
// config/imageman.php
'register_routes' => true,
'route_prefix' => 'imageman',
'route_middleware' => ['auth'],Two endpoints become available:
| Route | Description |
|---|---|
GET /imageman/{id}/{variant?} |
Proxy-stream the image through PHP |
GET /imageman/{id}/{variant?}/sign |
Redirect to a signed temporary URL (S3/GCS) |
For private disks (S3, GCS, Azure Blob):
// Default TTL from config (imageman.signed_url_ttl, default: 60 minutes)
$url = $image->temporaryUrl();
// Custom TTL in minutes
$url = $image->temporaryUrl(30);
// Specific variant
$url = $image->temporaryUrl(15, 'medium');The package ships with an Orchestra Testbench-based test suite:
composer install
./vendor/bin/phpunitUse Storage::fake() and the fakeImageFile() helper pattern:
use Illuminate\Support\Facades\Storage;
use IbrahimKaya\ImageMan\ImageManFacade as ImageMan;
public function test_user_can_upload_avatar(): void
{
Storage::fake('s3');
$user = User::factory()->create();
$file = \Illuminate\Http\UploadedFile::fake()->image('avatar.jpg', 200, 200);
$image = ImageMan::upload($file)->for($user)->collection('avatars')->disk('s3')->save();
$this->assertNotNull($image);
$this->assertSame('avatars', $image->collection);
$this->assertSame('s3', $image->disk);
Storage::disk('s3')->assertExists($image->directory . '/' . $image->filename);
}Pull requests are welcome. Please:
- Fork the repository and create a branch from
main. - Write tests covering your change.
- Ensure
./vendor/bin/phpunitpasses with no failures. - Follow PSR-12 code style.
- Submit a pull request with a clear description of the change.
The MIT License (MIT). See LICENSE for details.