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.
- Seamless Integration: Add a complete image management UI to your models with a single trait and two Blade components.
- Standalone & Dependency-Free: Works out-of-the-box with no need for external libraries like Glide.
- Automatic Thumbnail Generation: Natively processes and creates thumbnails for fast-loading galleries.
- Full Management UI: Includes drag & drop reordering, main image selection, lightbox preview, and secure deletion.
- Highly Customizable: Configure everything from image dimensions and quality to storage disks and route middleware.
- Reliable URL Generation: Uses Laravel's native storage URL methods for consistent image URLs across all environments.
- Broad Compatibility: Supports Laravel 8, 9, 10, and 11.
- PHP 7.4 or higher
- Laravel 8.0 or higher
- ext-exif (for automatic image orientation correction)
- ext-gd (for image processing)
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
Note: The legacy alias dropzone-enhanced:install
still works.
3. Run Migrations
php artisan migrate
4. Link Storage Ensure your public storage disk is linked so images are accessible.
php artisan storage:link
The package automatically corrects image orientation based on EXIF data from mobile photos:
- Auto-detection: Reads EXIF orientation data from uploaded images
- Smart correction: Applies rotation/flipping to both original and thumbnails
- Fallback handling: Gracefully handles images without EXIF data
- Performance optimized: Only processes JPEG images with orientation data
- PHP
ext-exif
extension enabled - JPEG images with EXIF metadata
Images will display correctly oriented regardless of how they were captured on mobile devices.
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 :lightbox="true" :model="$product" />
<button type="submit">Save Changes</button>
</form>
@endsection
- The
<x-dropzone-enhanced::area />
component provides the Dropzone interface to upload new images, which are automatically associated with the same$product
. - The
<x-dropzone-enhanced::photos />
component displays the gallery of already uploaded images for the given$product
, enabling management actions (reorder, delete, set main).
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 |
This component displays and manages existing photos for a model.
Parameter | Type | Description | Default |
---|---|---|---|
:model |
Model |
Required. The Eloquent model instance whose photos you want to display. | |
:lightbox |
bool |
Enables or disables the lightbox preview when clicking an image. | true |
The 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 thumbnail URL of the main photo (default dimensions from config)
$thumbUrl = $product->getMainPhotoThumbnailUrl();
// Get custom processed images (NEW in v2.1)
$mainPhoto = $product->mainPhoto();
$customUrl = $mainPhoto?->getUrl('400x400'); // Square 400x400
$webpUrl = $mainPhoto?->getUrl('800x600', 'webp'); // WebP format
$qualityUrl = $mainPhoto?->getUrl('400x400', 'jpg', 85); // Custom quality
// 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();
Create a custom controller to extend the package's functionality:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use MacCesar\LaravelDropzoneEnhanced\Http\Controllers\DropzoneController;
use MacCesar\LaravelDropzoneEnhanced\Models\Photo;
class CustomDropzoneController extends DropzoneController
{
public function upload(Request $request)
{
// Add custom validation rules
$request->validate([
'file' => 'required|image|mimes:jpeg,png,webp|dimensions:min_width=800,min_height=600',
'directory' => 'required|string',
'model_id' => 'required|integer',
'model_type' => 'required|string',
]);
// Custom processing before upload
$file = $request->file('file');
// Add watermark, custom processing, etc.
$this->processImageBeforeUpload($file);
// Call parent upload method
return parent::upload($request);
}
private function processImageBeforeUpload($file)
{
// Your custom image processing logic here
// Example: Add watermark, EXIF data removal, etc.
}
protected function userCanDeletePhoto(Request $request, Photo $photo, $model)
{
// Add custom authorization logic
if ($model instanceof \App\Models\Product) {
// Check if user owns the product's company
if ($model->company_id !== auth()->user()->company_id) {
return false;
}
}
// Call parent method for default checks
return parent::userCanDeletePhoto($request, $photo, $model);
}
}
Then register your custom controller in your routes:
// In routes/web.php
use App\Http\Controllers\CustomDropzoneController;
Route::post('dropzone/upload', [CustomDropzoneController::class, 'upload']);
Route::delete('dropzone/photos/{id}', [CustomDropzoneController::class, 'destroy']);
Handle different image categories for the same model:
{{-- 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);
- ✅ More intuitive:
getUrl()
for all image processing - ✅ Cleaner separation:
getThumbnailUrl()
for defaults only - ✅ More flexible: Support for WebP, PNG, custom quality
- ✅ Better performance: Dynamic generation only when needed
// 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
- Installer command: preferred
php artisan dropzoneenhanced:install
; aliasphp artisan dropzone-enhanced:install
. - Publish tags (both work):
- Config:
dropzoneenhanced-config
(alias:dropzone-enhanced-config
) - Migrations:
dropzoneenhanced-migrations
(alias:dropzone-enhanced-migrations
) - Views:
dropzoneenhanced-views
(alias:dropzone-enhanced-views
) - Lang:
dropzoneenhanced-lang
(alias:dropzone-enhanced-lang
) - Assets:
dropzoneenhanced-assets
(alias:dropzone-enhanced-assets
) You can now editconfig/dropzone.php
to change default image sizes, storage disks, route middleware, and more.
- Config:
The package includes a comprehensive and robust authorization system for photo deletion to prevent unauthorized actions. It performs a series of checks for authenticated users (model ownership, isAdmin
methods, Gates) and provides secure options for unauthenticated scenarios (session tokens, access keys).
For full details on customizing authorization logic, please refer to the extensive comments in the config/dropzone.php
file and the source code of the DropzoneController
.
Always validate file types both on the client and server side:
// Server-side validation (automatically handled by the package)
// The DropzoneController validates with: 'file' => 'required|file|image|max:' . config('dropzone.images.max_filesize')
// For custom validation, extend the controller:
class CustomDropzoneController extends DropzoneController
{
public function upload(Request $request)
{
$request->validate([
'directory' => 'required|string',
'model_id' => 'required|integer',
'model_type' => 'required|string',
'file' => 'required|image|mimes:jpeg,png,webp|max:5120', // 5MB max
]);
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 }}"
/>
The package provides multiple authorization layers. The userCanDeletePhoto()
method checks:
-
Photo ownership:
$photo->user_id === auth()->id()
-
Model ownership:
$model->user_id === auth()->id()
-
User relationship:
$model->user() && $model->user->id === auth()->id()
-
Custom ownership:
$model->isOwnedBy(auth()->user())
-
Admin check:
auth()->user()->isAdmin()
-
Laravel Gates:
auth()->can('delete-photos')
-
Spatie Permissions:
auth()->user()->hasPermissionTo('delete photos')
To customize authorization, extend the controller:
class CustomDropzoneController extends DropzoneController
{
protected function userCanDeletePhoto(Request $request, Photo $photo, $model)
{
// Add your custom authorization logic
if ($model instanceof Product && $model->company_id !== auth()->user()->company_id) {
return false;
}
// Call parent method for default checks
return parent::userCanDeletePhoto($request, $photo, $model);
}
}
Review your security settings in config/dropzone.php
:
'security' => [
// IMPORTANT: Keep this false in production
'allow_all_authenticated_users' => false,
// Set a strong access key for API requests
'access_key' => env('DROPZONE_ACCESS_KEY', null),
],
'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:
// In routes/web.php or your RouteServiceProvider
Route::middleware(['throttle:uploads'])->group(function () {
// Dropzone routes are automatically registered
});
// 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');
}
}
Create API endpoints for mobile or SPA applications:
// routes/api.php
use App\Http\Controllers\Api\DropzoneApiController;
Route::middleware('auth:sanctum')->group(function () {
Route::post('photos/upload', [DropzoneApiController::class, 'upload']);
Route::delete('photos/{photo}', [DropzoneApiController::class, 'destroy']);
Route::post('photos/{photo}/main', [DropzoneApiController::class, 'setMain']);
Route::post('photos/reorder', [DropzoneApiController::class, 'reorder']);
});
API Controller:
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use MacCesar\LaravelDropzoneEnhanced\Http\Controllers\DropzoneController;
use MacCesar\LaravelDropzoneEnhanced\Models\Photo;
class DropzoneApiController extends DropzoneController
{
public function upload(Request $request)
{
try {
$response = parent::upload($request);
$data = $response->getData();
if ($data->success) {
return response()->json([
'success' => true,
'photo' => [
'id' => $data->photo->id,
'url' => $data->photo->getUrl(),
'thumbnail' => $data->photo->getThumbnailUrl(),
'filename' => $data->photo->original_filename,
'size' => $data->photo->size,
'is_main' => $data->photo->is_main,
]
]);
}
return $response;
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Upload failed',
'error' => $e->getMessage()
], 422);
}
}
public function destroy(Photo $photo)
{
try {
// Use the package's authorization logic
if (!$this->userCanDeletePhoto(request(), $photo, $photo->photoable)) {
return response()->json([
'success' => false,
'message' => 'Unauthorized'
], 403);
}
$success = $photo->deletePhoto();
return response()->json([
'success' => $success,
'message' => $success ? 'Photo deleted successfully' : 'Failed to delete photo'
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Delete failed',
'error' => $e->getMessage()
], 500);
}
}
}
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 correctAPP_URL
-
Verify the
disk
configuration inconfig/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 public URL
Note: As of v2.1.5, URL generation has been optimized to use Laravel's native
Storage::disk()->url()
method, which automatically handles domain consistency across all environments (localhost, Herd, Valet, production).
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 inconfig/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.
This package uses NPM to manage Dropzone.js assets. For contributors:
Asset workflow (maintainers only):
- Script:
scripts/build-assets.js
copies fromnode_modules/dropzone/dist/
toresources/assets/
. - Files:
dropzone-min.js
,dropzone-min.js.map
,dropzone.css
,dropzone.css.map
. - Publish:
php artisan vendor:publish --tag=dropzoneenhanced-assets
(alias:dropzone-enhanced-assets
). - Consumers don’t need NPM; maintainers run these when updating Dropzone.
# 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.