Enhanced Dropzone.js component for Laravel with file upload, thumbnails, and photo management
maccesar/laravel-dropzone-enhanced is a Laravel package for enhanced dropzone.js component for laravel with file upload, thumbnails, and photo management.
It currently has 1 GitHub stars and 244 downloads on Packagist (latest version v4.0.0).
Install it with composer require maccesar/laravel-dropzone-enhanced.
Discover more Laravel packages by maccesar
or browse all Laravel packages to compare alternatives.
Last updated
A powerful and customizable Laravel package that enhances Dropzone.js to provide an elegant and efficient image upload and management solution for your Eloquent models.
cache/ directory — clean up everything with one command or rm -rf storage/app/public/cache..env configuration hassles.src/srcset helpers on models and photos (including raw storage paths) for quick, optimized URLs.1. Install via Composer
composer require maccesar/laravel-dropzone-enhanced
2. Run the Installer This command publishes the config file, migrations, and assets.
php artisan dropzoneenhanced:install
Installation Options:
php artisan dropzoneenhanced:install --no-interaction
Note: The legacy alias dropzone-enhanced:install still works.
3. Run Migrations (skip if using --no-interaction)
php artisan migrate
4. Link Storage Ensure your public storage disk is linked so images are accessible.
php artisan storage:link
5. Configure authorization
The component places the Eloquent model class, ID, directory, and locale in a signed upload URL. No model registry is required, and modifying any of those values invalidates the request.
For private uploads and photo management, define the Gate in your application's
authorization provider. Those operations are denied when the Gate is absent or
returns false:
use Illuminate\Support\Facades\Gate;
use MacCesar\LaravelDropzoneEnhanced\Models\Photo;
Gate::define('dropzone.manage-photos', function (
$user,
string $action,
$model,
?Photo $photo = null
): bool {
return $model->user_id === $user->id;
});
The action is one of upload, delete, set-main, view-main-status,
reorder, or update-locale.
Visitors can upload without registering when the application opts in explicitly:
'routes' => [
'middleware' => ['web', 'throttle:20,1'],
],
'security' => [
'allow_public_uploads' => true,
],
The component still creates a signed URL containing the server-selected model,
directory, and locale. Visitors cannot alter that context. MIME, size, pixel,
file-count, and warm-thumbnail limits remain active, and user_id is stored as
null. This option bypasses the Gate only for upload; delete, reorder, main-photo,
status, and locale operations remain denied without authorization.
This is a security-focused major upgrade:
routes and security keys into the published config.allow_public_uploads
for upload-only public forms.warmSizes, warmFactor, and warmFormat component props; configure
their snake-case equivalents under images instead.auth for a public upload form. The
package always applies signed directly to its upload route.database section before running the package migration.The previous access-key, public-delete, convention-based ownership, and arbitrary model-class behaviors have been removed. Management requests now require an explicit Gate decision; public upload is a separate opt-in.
The package automatically corrects image orientation based on EXIF data from mobile photos:
ext-exif extension enabledImages will display correctly oriented regardless of how they were captured on mobile devices.
The package can optionally use relative URLs (e.g., /storage/images/photo.jpg) instead of absolute URLs (e.g., http://localhost:8000/storage/images/photo.jpg). This provides several benefits:
APP_URL in your .env file without it affecting image URLsControl URL generation behavior in config/dropzone.php:
'images' => [
// Use relative URLs (e.g., /storage/...) instead of absolute URLs (e.g., http://localhost:8000/storage/...)
// This prevents issues with APP_URL in .env and makes URLs work across different environments
'use_relative_urls' => true, // Default: false (disabled for backward compatibility)
],
Note: This feature is disabled by default to maintain backward compatibility with existing installations.
To enable relative URLs (recommended for most use cases):
Step 1: Publish or update your config
php artisan vendor:publish --tag=dropzoneenhanced-config --force
Step 2: Enable the feature in config/dropzone.php
'images' => [
'use_relative_urls' => true,
],
Step 3: Clear config cache
php artisan config:clear
Option 2: Generate absolute URLs on-demand
// For a specific photo
$relativeUrl = $photo->getUrl(); // /storage/images/photo.jpg
$absoluteUrl = url($photo->getUrl()); // http://yourdomain.com/storage/images/photo.jpg
// In Blade templates
<img src="{{ url($photo->getUrl()) }}" alt="Photo">
If you're upgrading from v2.1.7 or earlier:
php artisan vendor:publish --tag=dropzoneenhanced-config --force'use_relative_urls' => true in config/dropzone.phpphp artisan config:clearAPP_URL in .envWhen pre_resize is enabled, the package resizes images in the browser before uploading to avoid sending unnecessarily large files to the server. The maximum dimensions are controlled by default_dimensions in config.
Unlike Dropzone's built-in resize (which uses default canvas interpolation producing aliased, pixelated results), this package uses a custom transformFile hook that sets imageSmoothingQuality = 'high' on the canvas context, enabling Lanczos/bicubic interpolation in modern browsers — the same quality you'd get from Photoshop or ImageMagick.
// config/dropzone.php
'images' => [
'pre_resize' => true, // Resize in browser before upload
'default_dimensions' => '1386x2100', // Max width × height to keep
'quality' => 100, // JPEG output quality (0–100)
],
Original photo: 4032×3024 (12 MB)
↓ Browser resizes with bicubic interpolation
Upload to server: 1386×1039 (sharp, ~400 KB)
↓ Server generates thumbnails from this
Storefront: 462×346 webp, 924×693 webp, 1386×1039 webp
Setting pre_resize: false uploads the original file (useful when you need the full resolution on the server).
All generated thumbnails are stored in a central .cache/ directory at the root of your storage disk. This makes cache management simple — no need to hunt down thumbnails/ folders scattered throughout your storage tree.
storage/app/public/
├── .cache/ ← All generated thumbnails here
│ └── products/
│ └── 16/
│ ├── 462x700/
│ │ ├── abc123.webp ← WebP variant
│ │ └── abc123.jpg ← JPG variant (if requested)
│ ├── 924x1400/
│ │ └── abc123.webp
│ └── 96x145/
│ └── abc123.webp
└── products/
└── 16/
└── abc123.jpg ← Original images (untouched)
The format belongs in the file extension — no redundant _webp suffix in folder names. All format variants of the same dimensions share the same folder.
Via Artisan command:
# With confirmation prompt
php artisan dropzoneenhanced:clear-thumbnails
# Skip confirmation (useful for deploy scripts)
php artisan dropzoneenhanced:clear-thumbnails --force
# Use a specific disk
php artisan dropzoneenhanced:clear-thumbnails --disk=s3 --force
# Keep specific dimensions, delete everything else (crop variants like
# 640x360_top of a kept dimension are preserved too)
php artisan dropzoneenhanced:clear-thumbnails --keep=640x360,960x540 --force
--keep is the tool for standardizing sizes: after changing the dimensions your app requests, old dimension directories become orphans that nothing will ever serve again — list the sizes you actually use and everything else is removed.
Directly on the server:
rm -rf storage/app/public/.cache
After clearing, thumbnails regenerate on demand when pages are accessed, or you can pre-generate them with your own warm-up command.
// config/dropzone.php
'storage' => [
'disk' => 'public',
'directory' => 'images',
'thumbnail_cache_path' => '.cache', // Customize the cache directory name
],
By default, thumbnails are never generated larger than the original image. Requesting 1920x1080 from a 640px-wide photo would produce an interpolated upscale that weighs more than the original and looks worse — instead, the request is clamped to the largest real crop at the requested aspect ratio (640x360 in this example). Both src()/srcset() and warm generation honor the clamp, and srcset() drops multiplier entries that collapse to an already-listed variant.
If your app genuinely needs upscaled variants:
// config/dropzone.php
'images' => [
'thumbnails' => [
'allow_upscale' => true, // default: false
],
],
Resolved thumbnail URLs are stored in Laravel's cache to skip a Storage::exists() check per request. With the file cache driver every cached URL costs one inode (one photo × several dimensions × formats adds up fast), which can exhaust file-count quotas on shared hosting. If that's your case, disable it — the generated image files themselves are unaffected:
// config/dropzone.php
'images' => [
'thumbnails' => [
'cache_urls' => false, // default: true
],
],
cache/ → .cache/Breaking: the default thumbnail_cache_path changed from cache to .cache (hidden directory, harder to confuse with app data). If you kept the old default, either set 'thumbnail_cache_path' => 'cache' explicitly in your published config, or migrate the directory:
# Move existing thumbnails to the new location
php artisan dropzoneenhanced:migrate-cache-path --force
# Or just drop the old directory and let thumbnails regenerate on demand
php artisan dropzoneenhanced:migrate-cache-path --delete --force
# Custom directories
php artisan dropzoneenhanced:migrate-cache-path --from=cache --to=.cache --disk=public
Previous versions stored thumbnails in thumbnails/ subfolders next to each original photo (e.g., products/16/thumbnails/462x700_webp/). After upgrading to v2.5.0, old thumbnails are no longer served. Regenerate them:
# Remove old scattered thumbnails (optional but recommended)
find storage/app/public -type d -name "thumbnails" -exec rm -rf {} + 2>/dev/null; true
# Pre-generate in new central location
php artisan products:warm-images # or your equivalent command
This guide shows the most common use case: managing photos for an existing model in an edit form.
Add the HasPhotos trait to any Eloquent model you want to associate with images.
// app/Models/Product.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use MacCesar\LaravelDropzoneEnhanced\Traits\HasPhotos;
class Product extends Model
{
use HasPhotos;
// ... your other model properties
}
In your Blade view (e.g., resources/views/products/edit.blade.php), add the two components. They work together to provide the full experience.
{{-- resources/views/products/edit.blade.php --}}
@extends('layouts.app')
@section('content')
<h1>Edit Product: {{ $product->name }}</h1>
<form action="{{ route('products.update', $product) }}" method="POST">
@csrf
@method('PUT')
{{-- Your other form fields --}}
<div>
<label for="name">Product Name</label>
<input id="name" name="name" type="text" value="{{ $product->name }}">
</div>
<hr>
{{-- 1. UPLOAD NEW PHOTOS --}}
<h3>Add New Photos</h3>
<x-dropzone-enhanced::area :max-files="10" :max-filesize="5" :model="$product" directory="products" />
<hr>
{{-- 2. MANAGE EXISTING PHOTOS --}}
<h3>Manage Existing Photos</h3>
<p>Drag to reorder, click the star to set the main photo, or use the trash icon to delete.</p>
<x-dropzone-enhanced::photos :model="$product" />
<button type="submit">Save Changes</button>
</form>
@endsection
<x-dropzone-enhanced::area /> component provides the Dropzone interface to upload new images, which are automatically associated with the same $product.<x-dropzone-enhanced::photos /> component displays the gallery of already uploaded images for the given $product, enabling management actions (reorder, delete, set main).<x-dropzone-enhanced::area />This component provides the file upload interface.
| Parameter | Type | Description | Default |
| :----------------- | :--------- | :-------------------------------------------------------------------------------------------------- | :--------------------------------------------- |
| :model | Model | Required. The Eloquent model instance to attach photos to. | |
| directory | string | Required. The subdirectory within your storage disk to save the images. | |
| dimensions | string | Max dimensions for resize (e.g., "1920x1080"). | config('dropzone.images.default_dimensions') |
| preResize | bool | Whether to resize the image in the browser before upload. Set false to preserve original quality. | config('dropzone.images.pre_resize') |
| maxFiles | int | Maximum number of files allowed to be uploaded. | config('dropzone.images.max_files') |
| maxFilesize | int | Maximum file size in MB. | config('dropzone.images.max_filesize') |
| reloadOnSuccess | bool | If true, the page will automatically reload after all uploads are successfully completed. | false |
| keepOriginalName | bool | If true, store files using the sanitized original filename (adds numeric suffix on collisions). | false |
| locale | string | Assign uploaded photos to a locale (requires multilingual enabled). | null |
Example: keep original filenames in a custom directory
<x-dropzone-enhanced::area
:model="$product"
directory="uploaded-files"
:keepOriginalName="true"
/>
By default, thumbnails are generated on demand the first time a view calls $photo->src('462', 'webp'). Configure warm sizes on the server to generate them immediately after upload. Request data and component props cannot override these limits.
Dimension syntax — identical to src() / srcset():
'462') → height inferred from original aspect ratio'1200x675') → exact dimensionsSetting app-wide defaults in config/dropzone.php:
'images' => [
'warm_sizes' => ['462', '96'],
'warm_factor' => 2,
'warm_format' => 'webp',
],
'security' => [
'max_warm_sizes' => 10,
'max_width' => 12000,
'max_height' => 12000,
'max_pixels' => 40000000,
],
Performance: generation is synchronous within the upload request. For 3–5 sizes × factor 2–3 (6–15 thumbnails) the added time is typically < 3 seconds — an acceptable trade-off versus slow first-render on-demand generation. If you need truly async warming, dispatch a queued job in your model observer after upload.
The client-side previews Dropzone shows while files are queued are configurable via the previews block in config/dropzone.php:
'previews' => [
'display_width' => 180, // preview box width in px
'display_height' => 180, // preview box height in px
'thumbnail_width' => 576, // generated thumbnail width (larger than the box for crisp retina previews)
'thumbnail_height' => 576, // generated thumbnail height
'thumbnail_method' => 'crop', // 'crop' or 'contain'
],
The generated thumbnail is intentionally larger than the visible box so previews stay sharp on high-density displays.
<x-dropzone-enhanced::photos />This component displays and manages existing photos for a model. The view action opens a built-in lightbox with prev/next navigation and keyboard support.
| Parameter | Type | Description | Default |
| :-------------------- | :------- | :-------------------------------------------------------------------------- | :------------------------------------------------ |
| :model | Model | Required. The Eloquent model instance whose photos you want to display. | |
| thumbnailDimensions | string | Thumbnail size (e.g. 200x200, 400x300). | config('dropzone.images.thumbnails.dimensions') |
| locale | string | Filter photos by locale (requires multilingual enabled). | null |
HasPhotos TraitThe trait adds several useful methods to your model:
// Get all associated photos as a Collection (ordered by sort_order)
$product->photos;
// Get the main photo model instance
$photo = $product->mainPhoto();
// Get the URL of the main photo (original)
$url = $product->getMainPhotoUrl();
// Get the URL of the main photo with processing options
// Signature: getMainPhotoUrl($dimensions, $format, $quality, $cropPosition)
$squareUrl = $product->getMainPhotoUrl('400x400'); // Square 400x400
$webpUrl = $product->getMainPhotoUrl('800x600', 'webp'); // WebP format
$qualityUrl = $product->getMainPhotoUrl('400x400', 'jpg', 85); // Custom quality
$topCropUrl = $product->getMainPhotoUrl('400x400', 'webp', 90, 'top'); // Custom crop position
// Get the thumbnail URL of the main photo (default dimensions from config)
$thumbUrl = $product->getMainPhotoThumbnailUrl();
$thumbUrlTop = $product->getMainPhotoThumbnailUrl('400x400', 'top'); // Override crop position
// Alternatively, get the main photo model and call getUrl() directly
$mainPhoto = $product->mainPhoto();
$customUrl = $mainPhoto?->getUrl('400x400'); // Square 400x400
$webpUrl = $mainPhoto?->getUrl('800x600', 'webp'); // WebP format
$qualityUrl = $mainPhoto?->getUrl('400x400', 'jpg', 85); // Custom quality
$topCropUrl = $mainPhoto?->getUrl('400x400', 'webp', 90, 'top'); // Custom crop position
// Set a specific photo as the main one
$product->setMainPhoto($photoId);
// Check if the model has any photos
if ($product->hasPhotos()) {
// ...
}
// Delete all photos associated with the model
$product->deleteAllPhotos();
// Quick helpers (NEW)
$product->src('300'); // Main photo, width-only; keeps aspect ratio
$product->src('300x300', 'webp', null, 'top'); // Main photo with top crop
$product->srcset('300x200', 3, 'jpg'); // 1x/2x/3x srcset for main photo
$photo->src('400'); // Specific Photo model, width-only
$photo->src('400x400', 'webp', null, 'bottom'); // Specific Photo model with bottom crop
$photo->srcset('400x300', 2, 'webp'); // Srcset for a Photo model
$product->srcFromPath('clients/avatar/main-photo.jpg', '300', 'webp'); // Any storage path
$product->srcsetFromPath('clients/avatar/main-photo.jpg', '300x300', 3, 'jpg'); // Srcset from storage path
These helpers work with the HasPhotos trait and the Photo model.
$model->src('300'); // width-only; keeps aspect ratio; uses mainPhoto(), fallback to first $model->srcset('300x200', 3); // 1x/2x/3x with the given dimensions
**Photo instance shortcuts**
```php
$photo->src('400'); // width-only; keeps aspect ratio from the original
$photo->srcset('400x300', 2, 'jpg'); // srcset 1x/2x in JPG
$model->srcFromPath('clients/avatar/main-photo.jpg', '320', 'webp'); $model->srcsetFromPath('clients/avatar/main-photo.jpg', '320x320', 3, 'jpg'); $model->srcFromPath('clients/avatar/main-photo.jpg', '320x320', 'webp', null, null, 'top'); // Crop from top
Notes:
If you pass width-only (`'300'`), height is inferred from the original aspect ratio; if it cannot be inferred, you get the original URL as 1x.
Respects `dropzone.storage.disk`, `dropzone.images.thumbnails.*`, and `use_relative_urls`.
`crop_position` is configurable globally (`config('dropzone.images.thumbnails.crop_position')`) and can be overridden per-call (e.g., `'top'`, `'bottom'`, `'left'`, `'right'`, `'top-left'`, etc.).
Internally uses `mainPhoto()` and falls back to the first photo when none is marked as main.
### Advanced Customization Examples
#### Custom Upload Controller
The bundled upload route requires its signed model/directory context. Replacing it
with an unsigned custom route removes that protection. Prefer model observers or a
separate image-processing service after upload. If a custom controller is required,
preserve `signed`, `web`, throttle, and the selected private/public authorization mode.
#### Multiple Upload Areas for Different Photo Types
Handle different image categories for the same model:
```blade
{{-- Main product gallery --}}
<div class="mb-8">
<h3 class="mb-4 text-lg font-semibold">Product Gallery</h3>
<x-dropzone-enhanced::area
:maxFiles="10"
:model="$product"
:preResize="true"
dimensions="1200x800"
directory="products/{{ $product->id }}/gallery"
/>
<x-dropzone-enhanced::photos
:model="$product"
/>
</div>
{{-- Technical specifications images --}}
<div class="mb-8">
<h3 class="mb-4 text-lg font-semibold">Technical Specifications</h3>
<x-dropzone-enhanced::area
:maxFiles="5"
:model="$product"
dimensions="1920x1080"
directory="products/{{ $product->id }}/specs"
/>
</div>
{{-- Thumbnail/avatar images --}}
<div class="mb-8">
<h3 class="mb-4 text-lg font-semibold">Product Thumbnails</h3>
<x-dropzone-enhanced::area
:maxFiles="3"
:model="$product"
:preResize="true"
dimensions="400x400"
directory="products/{{ $product->id }}/thumbs"
/>
</div>
Access and manipulate photo metadata:
// Get photo information
$photo = $product->photos->first();
echo $photo->filename; // UUID filename
echo $photo->original_filename; // Original upload name
echo $photo->extension; // File extension
echo $photo->mime_type; // MIME type
echo $photo->size; // File size in bytes
echo $photo->width; // Image width
echo $photo->height; // Image height
echo $photo->sort_order; // Display order
echo $photo->is_main; // Boolean main status
// Get URLs
echo $photo->getUrl(); // Original image URL
echo $photo->getThumbnailUrl(); // Default thumbnail (from config)
echo $photo->getPath(); // Storage path
// Custom image processing (NEW in v2.1)
echo $photo->getUrl('400x400'); // Square 400x400
echo $photo->getUrl('800x600', 'webp'); // Rectangular WebP
echo $photo->getUrl('400x400', 'jpg', 85); // Custom quality
echo $photo->getUrl('300x200', 'png'); // PNG format
// Photo operations
$photo->deletePhoto(); // Delete photo and files
Add custom scopes to filter photos:
// Create a custom Photo model extending the package's Photo
<?php
namespace App\Models;
use MacCesar\LaravelDropzoneEnhanced\Models\Photo as BasePhoto;
class Photo extends BasePhoto
{
// Custom scopes
public function scopeByDirectory($query, $directory)
{
return $query->where('directory', 'like', "%{$directory}%");
}
public function scopeMainPhotos($query)
{
return $query->where('is_main', true);
}
public function scopeLargeImages($query, $minWidth = 1000)
{
return $query->where('width', '>=', $minWidth);
}
// Custom accessors
public function getFileSizeFormattedAttribute()
{
$bytes = $this->size;
$units = ['B', 'KB', 'MB', 'GB'];
for ($i = 0; $bytes > 1024; $i++) {
$bytes /= 1024;
}
return round($bytes, 2) . ' ' . $units[$i];
}
public function getAspectRatioAttribute()
{
return $this->width / $this->height;
}
}
Use in your models:
// In your Product model, override the photos relationship
public function photos()
{
return $this->morphMany(\App\Models\Photo::class, 'photoable')
->orderBy('sort_order', 'asc');
}
// Then use custom scopes
$mainPhotos = $product->photos()->mainPhotos()->get();
$galleryPhotos = $product->photos()->byDirectory('gallery')->get();
$largeImages = $product->photos()->largeImages(1200)->get();
Configure dropzone behavior based on user permissions:
@php
$user = auth()->user();
$maxFiles = $user->isPremium() ? 20 : 5;
$maxSize = $user->isPremium() ? 10 : 2; // MB
$dimensions = $user->hasRole('photographer') ? '4000x3000' : '1920x1080';
$enablePreResize = !$user->hasRole('professional');
@endphp
<x-dropzone-enhanced::area
:dimensions="$dimensions"
:maxFiles="$maxFiles"
:maxFilesize="$maxSize"
:model="$product"
:preResize="$enablePreResize"
directory="products/{{ $product->category }}/{{ $user->id }}"
/>
Add JavaScript event listeners for custom behavior:
<script>
document.addEventListener('DOMContentLoaded', function () {
// Custom success handler
window.addEventListener('dropzone:success', function (event) {
const detail = event.detail;
console.log('Upload successful:', detail);
// Custom notifications
showToast('Image uploaded successfully!', 'success');
// Update UI counters
updatePhotoCounter();
// Auto-refresh gallery if needed
if (detail.isFirstPhoto) {
location.reload(); // Refresh to show new main photo
}
});
// Custom error handler
window.addEventListener('dropzone:error', function (event) {
const error = event.detail;
console.error('Upload failed:', error);
// Show detailed error messages
if (error.message.includes('validation')) {
showToast('Please check your file format and size', 'error');
} else if (error.message.includes('storage')) {
showToast('Storage error. Please try again.', 'error');
} else {
showToast('Upload failed: ' + error.message, 'error');
}
});
// Custom progress handler
window.addEventListener('dropzone:progress', function (event) {
const progress = event.detail.progress;
updateProgressBar(progress);
// Show/hide loading overlay
if (progress === 100) {
hideLoadingOverlay();
} else {
showLoadingOverlay();
}
});
});
function showToast(message, type) {
// Your notification system integration
}
function updatePhotoCounter() {
// Update photo count in UI
const count = document.querySelectorAll('.photo-item').length;
document.querySelector('#photo-count').textContent = count;
}
function updateProgressBar(progress) {
const progressBar = document.querySelector('#upload-progress');
if (progressBar) {
progressBar.style.width = progress + '%';
}
}
</script>
Override default styles with custom CSS:
/* Custom dropzone styling */
.dropzone-container .dropzone {
border: 2px dashed #4f46e5;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
transition: all 0.3s ease;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.dropzone:hover {
border-color: #3730a3;
background: linear-gradient(135deg, #eef2ff 0%, #ddd6fe 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(79, 70, 229, 0.15);
}
.dropzone.dz-drag-hover {
border-color: #1e40af;
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
transform: scale(1.02);
}
/* Custom photo gallery */
.photos-container .photos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.photos-container .photo-item {
position: relative;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
cursor: move;
}
.photos-container .photo-item:hover {
transform: scale(1.05);
box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.2);
}
.photos-container .photo-item.is-main {
border: 3px solid #fbbf24;
transform: scale(1.05);
}
.photos-container .photo-item.is-main::before {
content: "★";
position: absolute;
top: 8px;
left: 8px;
background: #fbbf24;
color: white;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
z-index: 10;
}
BEFORE (v2.0 and earlier):
// This worked but was confusing
$product->getMainPhotoThumbnailUrl('400x400', 'webp', 85);
AFTER (v2.1+):
// Simplified - thumbnails use config defaults only
$product->getMainPhotoThumbnailUrl(); // Default dimensions from config
// Enhanced - getUrl() now handles all custom processing
$mainPhoto = $product->mainPhoto();
$customUrl = $mainPhoto?->getUrl('400x400', 'webp', 85);
getUrl() for all image processinggetThumbnailUrl() for defaults only// Replace this:
$url = $product->getMainPhotoThumbnailUrl('400x400', 'webp');
// With this:
$mainPhoto = $product->mainPhoto();
$url = $mainPhoto?->getUrl('400x400', 'webp');
For deep customization, publish the configuration file:
php artisan vendor:publish --tag=dropzoneenhanced-config
# Alias supported: --tag=dropzone-enhanced-config
php artisan dropzoneenhanced:install; alias php artisan dropzone-enhanced:install.dropzoneenhanced-config (alias: dropzone-enhanced-config)dropzoneenhanced-migrations (alias: dropzone-enhanced-migrations)dropzoneenhanced-views (alias: dropzone-enhanced-views)dropzoneenhanced-lang (alias: dropzone-enhanced-lang)dropzoneenhanced-assets (alias: dropzone-enhanced-assets)
Translations publish to resources/lang/vendor/dropzone-enhanced so you can override package strings without clobbering app translations.
You can now edit config/dropzone.php to change default image sizes, storage disks, route middleware, and more.By default, Dropzone routes are registered behind authentication, CSRF protection, rate limiting, a signed upload URL, and the configured authorization Gate:
'routes' => [
'prefix' => '',
'middleware' => ['web', 'auth', 'throttle:60,1'],
],
Public uploads require the explicit security.allow_public_uploads opt-in shown in
the installation section. In that mode, remove auth from the route middleware but
keep web and throttle. Only upload bypasses the Gate; management stays protected.
Always validate file types both on the client and server side:
// Server-side validation (automatically handled by the package)
// The controller verifies the server-detected MIME type and maps it to a safe extension.
// For custom validation, extend the controller:
class CustomDropzoneController extends DropzoneController
{
public function upload(Request $request)
{
$request->validate([
'file' => 'required|file|max:5120',
]);
return parent::upload($request);
}
}
Organize uploads in a secure directory structure to prevent unauthorized access:
{{-- Good: Organized by model type --}}
<x-dropzone-enhanced::area
:model="$product"
directory="products"
/>
{{-- Better: Include model ID for isolation --}}
<x-dropzone-enhanced::area
:model="$product"
directory="products/{{ $product->id }}"
/>
{{-- Best: Include user context for multi-tenant apps --}}
<x-dropzone-enhanced::area
:model="$product"
directory="users/{{ auth()->id() }}/products/{{ $product->id }}"
/>
Private uploads and all management operations use one Gate. Keep authorization rules in the host application rather than extending the package controller:
Gate::define('dropzone.manage-photos', function ($user, $action, $model, $photo = null) {
return $model->user_id === $user->id;
});
Review your security settings in config/dropzone.php:
'routes' => [
// Default for new installations. Add your own policy/middleware here for
// model-specific rules, tenant checks, admin-panel permissions, etc.
'middleware' => ['web', 'auth', 'throttle:60,1'],
],
'security' => [
'allow_public_uploads' => false,
'authorization_ability' => 'dropzone.manage-photos',
'max_width' => 12000,
'max_height' => 12000,
'max_pixels' => 40000000,
],
'database' => [
'user_id_type' => 'bigint', // bigint, uuid, or ulid
'users_table' => null, // set to 'users' to create a FK
'users_key' => 'id',
],
'images' => [
// Limit file sizes to prevent abuse
'max_filesize' => 10000, // 10MB in KB
'max_files' => 10,
// Resize large images to save storage
'default_dimensions' => '1920x1080',
'pre_resize' => true,
],
The package uses polymorphic relationships with user tracking:
// The photos table includes security fields:
// - user_id: Who uploaded the photo
// - photoable_id/photoable_type: What model it belongs to
// Check photo ownership programmatically:
$photo = Photo::find($photoId);
if ($photo->user_id !== auth()->id()) {
abort(403, 'Unauthorized');
}
// Check model ownership:
$model = $photo->photoable;
if (!$model->isOwnedBy(auth()->user())) {
abort(403, 'Unauthorized');
}
Implement proper limits to prevent abuse:
<x-dropzone-enhanced::area
:model="$product"
:maxFiles="10" {{-- Limit number of files --}}
:maxFilesize="5" {{-- Limit file size (MB) --}}
:preResize="true" {{-- Resize before upload --}}
dimensions="1920x1080" {{-- Resize large images --}}
directory="products"
/>
Add rate limiting middleware to your routes:
// config/dropzone.php
'routes' => [
'middleware' => ['web', 'auth', 'throttle:uploads'],
],
// In app/Http/Kernel.php
protected $middlewareGroups = [
'web' => [
// ... other middleware
'throttle:60,1', // 60 requests per minute
],
];
Configure automatic image optimization to reduce file sizes and improve loading times:
{{-- Enable pre-resize for better performance --}}
<x-dropzone-enhanced::area
:model="$product"
:preResize="true" {{-- Resize in browser before upload (default) --}}
dimensions="1200x800" {{-- Resize to reasonable dimensions --}}
directory="products"
/>
{{-- Disable pre-resize to preserve original image quality --}}
<x-dropzone-enhanced::area
:model="$product"
:preResize="false" {{-- Upload original images without processing --}}
directory="products" {{-- Note: Files will be larger, uploads slower --}}
/>
Configure quality settings in config/dropzone.php:
'images' => [
'quality' => 100, // JPEG quality (1-100) - Default: 100 for maximum quality
'pre_resize' => true, // Client-side resize - Set false to preserve original images
'max_filesize' => 10000, // 10MB max in KB
'default_dimensions' => '1920x1080', // Max dimensions
'thumbnails' => [
'enabled' => true,
'dimensions' => '288x288', // Thumbnail size
],
],
The package uses the ImageProcessor service to generate thumbnails efficiently:
{{-- Use different thumbnail sizes for different contexts --}}
<x-dropzone-enhanced::photos
:model="$product"
thumbnailDimensions="200x200" {{-- Smaller for product lists --}}
/>
<x-dropzone-enhanced::photos
:model="$product"
thumbnailDimensions="400x300" {{-- Larger for detail views --}}
/>
Check thumbnail configuration:
// Get thumbnail URL with custom dimensions
$photo = $product->photos->first();
$thumbUrl = $photo->getThumbnailUrl('300x200');
// Default thumbnail from config
$defaultThumb = $photo->getThumbnailUrl(); // Uses config('dropzone.images.thumbnails.dimensions')
Optimize queries when working with photos:
// Eager load photos to avoid N+1 queries
$products = Product::with('photos')->get();
// Get only main photos
$products = Product::with(['photos' => function($query) {
$query->where('is_main', true);
}])->get();
// Order photos by sort_order (already done by HasPhotos trait)
$photos = $product->photos; // Automatically ordered by sort_order ASC
// Paginate photos for models with many images
$photos = $product->photos()->paginate(20);
Optimize storage usage and access patterns:
// Use appropriate storage disk for your needs
'storage' => [
'disk' => 'public', // For local development
// 'disk' => 's3', // For production with CDN
'directory' => 'images',
],
// Organize files in date-based directories to avoid too many files per folder
<x-dropzone-enhanced::area
:model="$product"
directory="products/{{ date('Y/m') }}/{{ $product->id }}"
/>
The ImageProcessor properly manages memory when generating thumbnails:
// The service automatically:
// 1. Creates image resources
// 2. Generates thumbnails with proper aspect ratio
// 3. Cleans up memory with imagedestroy()
// 4. Handles different image formats (JPEG, PNG, GIF, WebP)
// For very large images, ensure adequate PHP memory:
ini_set('memory_limit', '256M');
Implement lazy loading for better page performance:
{{-- The photos component includes lazy loading by default --}}
<img
class="photo-thumb"
src="{{ $photo->getThumbnailUrl() }}"
alt="{{ $photo->original_filename }}"
loading="lazy" {{-- Native lazy loading --}}
/>
Implement caching for frequently accessed data:
// Cache photo counts
public function getPhotoCountAttribute()
{
return Cache::remember(
"product_{$this->id}_photo_count",
3600, // 1 hour
fn() => $this->photos()->count()
);
}
// Cache main photo URL
public function getCachedMainPhotoUrl()
{
return Cache::remember(
"product_{$this->id}_main_photo_url",
3600,
fn() => $this->getMainPhotoUrl()
);
}
For production environments, consider using a CDN:
// Override the Photo model's getUrl() method for CDN
class Photo extends \MacCesar\LaravelDropzoneEnhanced\Models\Photo
{
public function getUrl()
{
$cdnUrl = config('app.cdn_url');
if ($cdnUrl) {
return $cdnUrl . '/' . $this->getPath();
}
return parent::getUrl();
}
}
Handle multiple photos efficiently:
// Delete multiple photos efficiently
public function deleteSelectedPhotos(array $photoIds)
{
$photos = $this->photos()->whereIn('id', $photoIds)->get();
foreach ($photos as $photo) {
$photo->deletePhoto(); // Handles file deletion + DB cleanup
}
}
// Reorder multiple photos in one operation
public function reorderPhotos(array $photoData)
{
foreach ($photoData as $item) {
Photo::where('id', $item['id'])
->update(['sort_order' => $item['order']]);
}
}
// Bulk update main photo status
public function setMainPhoto(int $photoId): bool
{
// Unset all main photos in one query
$this->photos()->update(['is_main' => false]);
// Set new main photo
return (bool) $this->photos()
->where('id', $photoId)
->update(['is_main' => true]);
}
Integrate the package with Livewire components for reactive interfaces:
<?php
namespace App\Http\Livewire;
use Livewire\Component;
use App\Models\Product;
class ProductGallery extends Component
{
public Product $product;
public $photos;
public $photoCount = 0;
protected $listeners = [
'photoUploaded' => 'refreshPhotos',
'photoDeleted' => 'refreshPhotos',
'photoReordered' => 'refreshPhotos',
];
public function mount(Product $product)
{
$this->product = $product;
$this->refreshPhotos();
}
public function refreshPhotos()
{
$this->photos = $this->product->photos()->get();
$this->photoCount = $this->photos->count();
}
public function deletePhoto($photoId)
{
$photo = $this->product->photos()->findOrFail($photoId);
$photo->deletePhoto();
$this->refreshPhotos();
session()->flash('message', 'Photo deleted successfully');
}
public function setMainPhoto($photoId)
{
$this->product->setMainPhoto($photoId);
$this->refreshPhotos();
session()->flash('message', 'Main photo updated');
}
public function render()
{
return view('livewire.product-gallery');
}
}
Livewire component view:
{{-- resources/views/livewire/product-gallery.blade.php --}}
<div>
@if (session()->has('message'))
<div class="alert alert-success">
{{ session('message') }}
</div>
@endif
<div class="mb-4">
<h3>Upload New Photos ({{ $photoCount }}/{{ config('dropzone.images.max_files', 10) }})</h3>
<x-dropzone-enhanced::area
:model="$product"
:reloadOnSuccess="false"
directory="products/{{ $product->id }}"
wire:ignore />
</div>
<div class="mt-6">
<h3>Manage Photos</h3>
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
@foreach ($photos as $photo)
<div class="group relative">
<img alt="{{ $photo->original_filename }}" class="{{ $photo->is_main ? 'ring-4 ring-yellow-400' : '' }} h-32 w-full rounded-lg object-cover" src="{{ $photo->getThumbnailUrl('200x200') }}">
<div class="absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">
<button class="mr-1 rounded-full bg-yellow-500 p-1 text-xs text-white" title="Set as main photo" wire:click="setMainPhoto({{ $photo->id }})">
★
</button>
<button class="rounded-full bg-red-500 p-1 text-xs text-white" title="Delete photo" wire:click="deletePhoto({{ $photo->id }})" wire:confirm="Are you sure you want to delete this photo?">
×
</button>
</div>
@if ($photo->is_main)
<div class="absolute bottom-2 left-2 rounded bg-yellow-500 px-2 py-1 text-xs text-white">
Main
</div>
@endif
</div>
@endforeach
</div>
</div>
</div>
<script>
// Listen for upload success and refresh Livewire component
window.addEventListener('dropzone:success', function(event) {
@this.call('refreshPhotos');
});
window.addEventListener('dropzone:error', function(event) {
// Handle upload errors in Livewire context
console.error('Upload failed:', event.detail);
});
</script>
If you prefer using Spatie MediaLibrary instead of the built-in Photo model:
// Alternative approach using Spatie MediaLibrary
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\HasMedia;
class Product extends Model implements HasMedia
{
use InteractsWithMedia;
public function registerMediaCollections(): void
{
$this->addMediaCollection('gallery')
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp'])
->singleFile(); // For single main image
$this->addMediaCollection('thumbnails')
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp']);
}
public function registerMediaConversions(Media $media = null): void
{
$this->addMediaConversion('thumb')
->width(288)
->height(288)
->sharpen(10);
$this->addMediaConversion('large')
->width(1920)
->height(1080)
->quality(90);
}
// Helper methods to work with both systems
public function getMainPhotoUrl()
{
if ($this->hasPhotos()) {
return $this->getMainPhotoUrl(); // Use package method
}
// Fallback to MediaLibrary
return $this->getFirstMediaUrl('gallery', 'large');
}
}
The bundled endpoints are session-based web routes and upload through signed URLs. For Sanctum, mobile clients, or third-party APIs, create a separate controller with token-specific authorization and issue a server-controlled upload context. Do not reuse the web controller through unsigned API routes.
Use the package with Inertia.js for Vue.js applications:
<!-- resources/js/Pages/Products/Edit.vue -->
<template>
<div>
<h1>Edit Product: {{ product.name }}</h1>
<!-- Upload Area -->
<div class="mb-8">
<h3>Upload New Photos</h3>
<DropzoneArea :model="product" directory="products" :max-files="10" :max-filesize="5" @upload-success="handleUploadSuccess" @upload-error="handleUploadError" />
</div>
<!-- Photo Gallery -->
<div class="mb-8">
<h3>Manage Photos ({{ photos.length }})</h3>
<PhotoGallery :photos="photos" @photo-deleted="handlePhotoDelete" @main-photo-changed="handleMainPhotoChange" @photos-reordered="handlePhotoReorder" />
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue'
import { Inertia } from '@inertiajs/inertia'
import DropzoneArea from '@/Components/DropzoneArea.vue'
import PhotoGallery from '@/Components/PhotoGallery.vue'
export default {
components: {
DropzoneArea,
PhotoGallery
},
props: {
product: Object,
photos: Array
},
setup(props) {
const photos = ref(props.photos)
const handleUploadSuccess = (photo) => {
photos.value.push(photo)
// Show success notification
this.$toast.success('Photo uploaded successfully')
}
const handleUploadError = (error) => {
this.$toast.error('Upload failed: ' + error.message)
}
const handlePhotoDelete = (photoId) => {
photos.value = photos.value.filter(photo => photo.id !== photoId)
this.$toast.success('Photo deleted successfully')
}
const handleMainPhotoChange = (photoId) => {
photos.value.forEach(photo => {
photo.is_main = photo.id === photoId
})
this.$toast.success('Main photo updated')
}
const handlePhotoReorder = (reorderedPhotos) => {
photos.value = reorderedPhotos
}
return {
photos,
handleUploadSuccess,
handleUploadError,
handlePhotoDelete,
handleMainPhotoChange,
handlePhotoReorder
}
}
}
</script>
Integrate with Filament for admin interfaces:
// app/Filament/Resources/ProductResource.php
use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
use MacCesar\LaravelDropzoneEnhanced\Traits\HasPhotos;
class ProductResource extends Resource
{
public static function form(Form $form): Form
{
return $form->schema([
// Other form fields...
Section::make('Photos')
->schema([
// Custom photo management component
ViewField::make('photos')
->view('filament.forms.dropzone-photos')
->viewData(fn($record) => [
'product' => $record,
'photos' => $record?->photos ?? collect(),
]),
]),
]);
}
}
Custom Filament view:
{{-- resources/views/filament/forms/dropzone-photos.blade.php --}}
<div class="space-y-4">
@if ($product)
<!-- Upload Area -->
<x-dropzone-enhanced::area
:maxFiles="10"
:maxFilesize="5"
:model="$product"
directory="products/{{ $product->id }}"
/>
<!-- Photos Gallery -->
@if ($photos->count() > 0)
<div class="mt-4 grid grid-cols-3 gap-4">
@foreach ($photos as $photo)
<div class="relative">
<img alt="{{ $photo->original_filename }}" class="{{ $photo->is_main ? 'ring-2 ring-primary-500' : '' }} h-32 w-full rounded object-cover" src="{{ $photo->getThumbnailUrl('200x200') }}">
@if ($photo->is_main)
<div class="bg-primary-500 absolute left-1 top-1 rounded px-2 py-1 text-xs text-white">
Main
</div>
@endif
</div>
@endforeach
</div>
@endif
@else
<p class="text-gray-500">Save the product first to add photos.</p>
@endif
</div>
Problem: Files are not uploading or dropzone area is not responsive.
Solutions:
Check that your model has the HasPhotos trait:
use MacCesar\LaravelDropzoneEnhanced\Traits\HasPhotos;
class Product extends Model
{
use HasPhotos;
}
Verify the routes are correctly registered:
php artisan route:list | grep dropzone
Should show: POST dropzone/upload, DELETE dropzone/photos/{id}, etc.
Check browser console for JavaScript errors
Ensure CSRF token is present in your page (required for web middleware)
Problem: Files upload but return 403/permission errors.
Solutions:
Check storage directory permissions:
chmod -R 775 storage/app/public/
Verify the storage link exists:
php artisan storage:link
Check your .env file has correct APP_URL
Verify the disk configuration in config/dropzone.php matches your storage setup
Problem: Files upload successfully but don't display in gallery.
Solutions:
Run storage link command:
php artisan storage:link
Clear application cache:
php artisan cache:clear
php artisan view:clear
Check that storage/app/public/ directory is writable
Verify your model relationship is working:
$product = Product::find(1);
dd($product->photos); // Should return a collection
Check the getUrl() method is returning valid URLs:
$photo = $product->photos->first();
dd($photo->getUrl()); // Should return a valid URL (relative or absolute based on config)
If you're seeing absolute URLs with localhost:8000 or wrong domain:
# Enable relative URLs feature to fix this issue
# Step 1: Republish config
php artisan vendor:publish --tag=dropzoneenhanced-config --force
# Step 2: Edit config/dropzone.php and set 'use_relative_urls' => true
# Step 3: Clear cache
php artisan config:clear
Note: As of v2.1.8, you can enable relative URLs (
/storage/...) to ensure consistency across all environments. This feature is opt-in (disabled by default) to maintain backward compatibility. Once enabled, it prevents issues withAPP_URLin.envaffecting image URLs.
Problem: Large files fail to upload.
Solutions:
Check PHP configuration in php.ini:
upload_max_filesize = 10M
post_max_size = 10M
max_execution_time = 300
memory_limit = 256M
Update your dropzone configuration:
<x-dropzone-enhanced::area
:model="$product"
:maxFilesize="10"
directory="products"
/>
Check the max_filesize setting in config/dropzone.php
Problem: Original images display but thumbnails don't generate.
Solutions:
Ensure GD extension is installed:
php -m | grep -i gd
Check thumbnail configuration in config/dropzone.php:
'thumbnails' => [
'enabled' => true,
'dimensions' => '288x288',
],
Verify thumbnail directories are created with proper permissions
Check logs for thumbnail generation errors:
tail -f storage/logs/laravel.log
Q: Can I upload files other than images? A: The package is designed for images, but you can modify the validation rules in the controller to accept other file types.
Q: How do I limit the number of files per model?
A: Use the :maxFiles parameter on the dropzone component:
<x-dropzone-enhanced::area :model="$product" :maxFiles="5" directory="products" />
Q: Can I customize the upload directory structure?
A: Yes, the directory parameter accepts nested paths:
<x-dropzone-enhanced::area :model="$product" directory="products/{{ $product->category }}" />
Q: How do I handle different image sizes for different models?
A: Use different dimensions parameters for each model:
<x-dropzone-enhanced::area :model="$product" dimensions="1920x1080" directory="products" />
<x-dropzone-enhanced::area :model="$user" dimensions="400x400" directory="avatars" />
Q: How do I customize thumbnail dimensions?
A: Use the thumbnailDimensions prop on the photos component:
<x-dropzone-enhanced::photos :model="$product" thumbnailDimensions="400x300" />
Q: Can I add custom validation rules?
A: Yes, extend the DropzoneController and override the upload method with your custom validation.
Q: Why are my image URLs showing http://localhost:8000 in production?
A: Enable the relative URLs feature (available since v2.1.8) to prevent this issue:
# Step 1: Republish the config to get the new setting
php artisan vendor:publish --tag=dropzoneenhanced-config --force
# Step 2: Edit config/dropzone.php and set 'use_relative_urls' => true
# Step 3: Clear config cache
php artisan config:clear
Q: How do I use absolute URLs instead of relative URLs?
A: Absolute URLs are the default behavior. The package only uses relative URLs if you explicitly enable it by setting 'use_relative_urls' => true in config/dropzone.php.
Q: Can I mix relative and absolute URLs? A: Yes, you can convert on-demand:
// Get relative URL (default)
$relativeUrl = $photo->getUrl(); // /storage/images/photo.jpg
// Convert to absolute when needed
$absoluteUrl = url($photo->getUrl()); // http://yourdomain.com/storage/images/photo.jpg
NEW in v2.2.0 - Manage locale-specific photos for multilingual applications. Perfect for sites where images contain text in different languages.
If you're upgrading from a previous version:
# 1. Update the package
composer update maccesar/laravel-dropzone-enhanced
# 2. Run the migration (REQUIRED - adds locale column)
php artisan migrate
# 3. Enable multilingual support in config/dropzone.php
# Set 'multilingual.enabled' => true
# 4. Start using locale prop in your views
# <x-dropzone-enhanced::area :model="$model" locale="es" />
⚠️ Important: After running composer update, you MUST run php artisan migrate to add the locale column to the photos table. The migration is backward-compatible and won't affect existing photos.
Note (v2.2.1): The photos table creation migration was renamed and guarded to ensure fresh installs run in the correct order while existing installs won't recreate the table. Just run php artisan migrate as usual.
Multilingual support allows you to upload and manage different images for each language in your application. Each locale has:
Enable multilingual support in config/dropzone.php:
'multilingual' => [
'enabled' => true,
],
That's it! When enabled, you can pass any locale string to components. No need to pre-configure locales or default language.
Run the migration to add the locale column:
php artisan migrate
The migration adds a nullable locale column to the photos table, ensuring 100% backward compatibility with existing photos.
@if($isMultilingual && $content->exists)
{{-- Spanish Images --}}
<x-adminkit::forms.card
title="{{ __('Spanish Images') }}"
icon="fas fa-images">
<x-dropzone-enhanced::area
:model="$content"
directory="content"
locale="es"
:maxFiles="10"
dimensions="1200x800"
/>
@if($content->hasPhotosForLocale('es'))
<x-dropzone-enhanced::photos
:model="$content"
locale="es"
/>
@endif
</x-adminkit::forms.card>
{{-- English Images --}}
<x-adminkit::forms.card
title="{{ __('English Images') }}"
icon="fas fa-globe">
<x-dropzone-enhanced::area
:model="$content"
directory="content"
locale="en"
:maxFiles="10"
dimensions="1200x800"
/>
@if($content->hasPhotosForLocale('en'))
<x-dropzone-enhanced::photos
:model="$content"
locale="en"
/>
@endif
</x-adminkit::forms.card>
@else
{{-- Non-multilingual mode (backward compatible) --}}
<x-dropzone-enhanced::area :model="$content" directory="content" />
<x-dropzone-enhanced::photos :model="$content" />
@endif
Use the photo-manager component to render expandable upload zones plus a unified gallery with locale filters:
<x-dropzone-enhanced::photo-manager
:model="$content"
directory="content"
:locales="[
['key' => null, 'label' => __('Generic'), 'color' => 'gray', 'badge' => 'GEN'],
['key' => 'es', 'label' => __('Spanish'), 'color' => 'blue', 'badge' => 'ES'],
['key' => 'en', 'label' => __('English'), 'color' => 'purple', 'badge' => 'EN'],
]"
defaultLocale="es"
thumbnailDimensions="200x200"
/>
This component is optional and does not replace the existing area and photos components. Use it when you want a compact multilingual experience on a single screen.
Key features:
Props:
model (required): Eloquent model with HasPhotos traitdirectory (required): storage directory for uploadslocales (required): array of locale configs
key: locale string or null for genericlabel: display labelcolor: gray|blue|purple (used for background tint)badge: short label (e.g. GEN, ES, EN) shown on hoverdefaultLocale (optional): initial active filter (use all for combined view)thumbnailDimensions (optional): gallery thumb size (default 288x288)Locale move behavior:
Dragging a photo into another locale updates its locale value. Main photo selection is not changed automatically—use the star button to set it.
// Get photos for specific locale
$spanishPhotos = $content->photosByLocale('es');
$englishPhotos = $content->photosByLocale('en');
// Get main photo for locale
$mainPhoto = $content->mainPhoto('es');
$mainPhotoUrl = $mainPhoto?->getUrl('800x600');
// Check if locale has photos
if ($content->hasPhotosForLocale('en')) {
// Has English photos
}
// Get all photos grouped by locale
$grouped = $content->photosGroupedByLocale();
// Returns: ['en' => Collection, 'es' => Collection, 'fr' => Collection]
// Delete all photos for specific locale
$content->deletePhotosForLocale('es');
{{-- Display main photo for current app locale --}}
<img src="{{ $content->mainPhoto()->getUrl('800x600') }}" alt="{{ $content->title }}">
{{-- Display main photo for specific locale --}}
<img src="{{ $content->mainPhoto('es')->getUrl('800x600') }}" alt="{{ $content->title }}">
{{-- Loop through photos for specific locale --}}
@foreach($content->photosByLocale('en') as $photo)
<img src="{{ $photo->getUrl('400x300') }}" alt="{{ $photo->original_filename }}">
@endforeach
{{-- Gallery with locale-aware photos --}}
<div class="gallery">
@foreach($content->photosByLocale(app()->getLocale()) as $photo)
<a href="{{ $photo->getUrl() }}" data-lightbox="gallery">
<img src="{{ $photo->getThumbnailUrl('300x300') }}">
</a>
@endforeach
</div>
Each locale maintains its own main photo:
// Set main photo for Spanish
$content->setMainPhoto($spanishPhotoId); // Only affects Spanish photos
// Set main photo for English
$content->setMainPhoto($englishPhotoId); // Only affects English photos
// Get main photo per locale
$spanishMain = $content->mainPhoto('es');
$englishMain = $content->mainPhoto('en');
Photos are sorted independently within each locale:
// Reordering Spanish photos doesn't affect English photos
Photo::where('photoable_id', $content->id)
->where('locale', 'es')
->update(['sort_order' => $newOrder]);
Run Migration - Adds nullable locale column (no data changes):
php artisan migrate
Enable Configuration - Update config/dropzone.php:
'multilingual' => [
'enabled' => true,
],
Update Views - Add locale prop to components where needed:
<x-dropzone-enhanced::area :model="$model" locale="es" />
Gradual Adoption - Enable per module/model as needed. Existing photos continue working without locale.
// Assign default locale to all existing photos
DB::table('photos')->whereNull('locale')->update(['locale' => 'en']);
// Or assign based on parent model's locale
$contents = Content::all();
foreach ($contents as $content) {
$content->photos()->whereNull('locale')
->update(['locale' => $content->locale ?? 'en']);
}
locale = null)multilingual.enabled = false)locale propThe package includes a composite index (photoable_type, photoable_id, locale) for efficient queries:
// Optimized query using index
$photos = Photo::where('photoable_type', Content::class)
->where('photoable_id', $contentId)
->where('locale', 'es')
->orderBy('sort_order')
->get();
Q: Can I have some models with locale support and others without?
A: Yes! Simply pass the locale prop only where needed. Models without locale prop work exactly as before.
Q: How do I handle user-uploaded content where locale isn't known?
A: Don't pass the locale prop. Photos without locale work independently of multilingual photos.
Q: Can I change a photo's locale after upload?
A: Yes, update the locale column directly:
$photo->update(['locale' => 'es']);
Q: What languages/locales can I use?
A: Locale codes up to 10 characters ('es', 'en', 'fr', 'en-US', etc.).
This package uses NPM to manage Dropzone.js assets. For contributors:
Asset workflow (maintainers only):
scripts/build-assets.js copies Dropzone and SortableJS from node_modules/ to resources/assets/.dropzone-min.js, dropzone-min.js.map, dropzone.css, dropzone.css.map, Sortable.min.js.php artisan vendor:publish --tag=dropzoneenhanced-assets (alias: dropzone-enhanced-assets).# Install dependencies
npm install
# Build assets from node_modules
npm run build-assets
# Update Dropzone.js to latest version
npm run update-dropzone
The package includes Dropzone.js 6.0.0-beta.2 with full source map support for debugging.
Please see CONTRIBUTING.md for details.
The MIT License (MIT). Please see License File for more information.