Laravel Image Upload with Preview and Multiple Images CRUD Example


In this tutorial, we'll create a Laravel application to upload, preview, and manage multiple images using jQuery and Bootstrap. You'll learn how to store images in the storage/app/public
directory, and perform full CRUD operations with validations.
1. Create Model, Migration & Controller
php artisan make:model Product -m
php artisan make:model ProductImage -m
php artisan make:controller ProductController --resource
php artisan storage:link
Define migrations with product and image fields, then migrate.
2. migration
๐ products
table
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
๐ product_images
table
Schema::create('product_images', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->onDelete('cascade');
$table->string('image_path');
$table->timestamps();
});
Run migration:
php artisan migrate
3: Define Relationships in Models
Product.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
use HasFactory;
protected $fillable = ['name'];
public function images()
{
return $this->hasMany(ProductImage::class);
}
}
ProductImage.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ProductImage extends Model
{
use HasFactory;
protected $fillable = ['product_id', 'image_path'];
public function product()
{
return $this->belongsTo(Product::class);
}
}
4: Set Up Routes
use App\Http\Controllers\ProductController;
Route::resource('product', ProductController::class);
Route::get('product-fetch', [ProductController::class, 'fetch'])->name('product.fetch');
5: Create Views
create layout : views/layouts/app.blade.php
<!DOCTYPE html>
<html>
<head>
<title>@yield('title', 'Image Upload App')</title>
<meta name="csrf-token" content="{{ csrf_token() }}">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.datatables.net/1.13.5/css/jquery.dataTables.min.css" rel="stylesheet" />
<link href="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
@stack('styles')
</head>
<body>
<div class="container mt-5">
@yield('content')
</div>
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
<script src="http://ajax.aspnetcdn.com/ajax/jquery.validate/1.11.1/jquery.validate.min.js"></script>
<script src="https://cdn.datatables.net/1.13.5/js/jquery.dataTables.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.js"></script>
<script>
@if (session('success'))
toastr.success("{{ session('success') }}");
@endif
@if (session('error'))
toastr.error("{{ session('error') }}");
@endif
</script>
@yield('scripts')
</body>
</html>
Create these files in resources/views/product/
:
index.blade.php
Shows DataTable with edit/delete buttons.
@extends('layouts.app')
@section('title', 'Products List')
@section('content')
<h2>Products</h2>
<a href="{{ route('product.create') }}" class="btn btn-primary mb-3">Create Product</a>
<table id="productTable" class="table table-bordered">
<thead>
<tr>
<th>Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
@endsection
@section('scripts')
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
var BASE_URL = "{{ url('/') }}";
$(document).ready(function() {
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
var table = $('#productTable').DataTable({
ajax: "{{ route('product.fetch') }}",
columns: [{
data: 'name'
},
{
data: 'id',
orderable: false,
searchable: false,
render: function(data, type, row) {
let html = "";
html += `<a href="${BASE_URL}/product/${row.id}/edit" class="btn btn-sm btn-info me-1">
<i class="bi bi-pencil-square"></i> Edit
</a>`;
html += `<button type="button" class="btn btn-sm btn-danger" onclick="deleteproduct('${row.id}')">
<i class="bi bi-trash"></i> Delete
</button>`;
return html;
}
}
]
});
});
function deleteproduct(id) {
Swal.fire({
title: 'Are you sure you want to delete this product?',
text: "This action cannot be undone!",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Delete'
}).then((result) => {
if (result.isConfirmed) {
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
var userURL = BASE_URL + "/product/" + id;
$.ajax({
url: userURL,
type: "DELETE",
dataType: "json",
success: function(response) {
toastr.success(response.message)
$('#productTable').DataTable().ajax.reload(null, false);
}
});
}
})
}
</script>
@endsection
create.blade.php
Form to upload multiple images with jQuery preview.
@extends('layouts.app')
@section('title', 'Upload Images')
@section('content')
<div class="container mt-4">
<h2>Upload Multiple Images</h2>
<form id="uploadForm" method="POST" action="{{ route('product.store') }}" enctype="multipart/form-data">
@csrf
{{-- Product Name --}}
<div class="mb-3">
<label for="name" class="form-label">Product Name</label>
<input type="text" name="name" id="name" class="form-control" required placeholder="Enter product name">
<span class="text-danger" id="nameError"></span>
</div>
{{-- Image Upload --}}
<div class="mb-3">
<label for="images" class="form-label">Select Images</label>
<input type="file" id="imageInput" name="images[]" accept="image/*" multiple class="form-control">
<div id="imagePreview" class="d-flex flex-wrap gap-2 mt-3"></div>
<span class="text-danger" id="imageError"></span>
</div>
<button type="submit" class="btn btn-primary mt-2">Submit</button>
</form>
</div>
<style>
.preview-image {
width: 120px;
height: 120px;
object-fit: cover;
margin: 5px;
border: 1px solid #ccc;
position: relative;
border-radius: 8px;
}
.remove-btn {
position: absolute;
top: -5px;
right: -5px;
background: red;
color: white;
border: none;
border-radius: 50%;
width: 20px;
height: 20px;
font-size: 14px;
cursor: pointer;
}
.image-box {
position: relative;
display: inline-block;
}
</style>
@endsection
@section('scripts')
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-validation@1.19.5/dist/jquery.validate.min.js"></script>
<script>
let selectedFiles = [];
$('#imageInput').on('change', function(e) {
let files = Array.from(e.target.files);
selectedFiles = selectedFiles.concat(files);
displayImages();
});
function displayImages() {
$('#imagePreview').empty();
selectedFiles.forEach((file, index) => {
let reader = new FileReader();
reader.onload = function(e) {
const imgBox = `
<div class="image-box">
<img src="${e.target.result}" class="preview-image">
<button type="button" class="remove-btn" onclick="removeImage(${index})">ร</button>
</div>
`;
$('#imagePreview').append(imgBox);
};
reader.readAsDataURL(file);
});
updateInputFiles();
}
function removeImage(index) {
selectedFiles.splice(index, 1);
displayImages();
}
function updateInputFiles() {
let dataTransfer = new DataTransfer();
selectedFiles.forEach(file => dataTransfer.items.add(file));
$('#imageInput')[0].files = dataTransfer.files;
}
$.validator.addMethod("extension", function(value, element, param) {
param = typeof param === "string" ? param.replace(/,/g, '|') : "png|jpe?g|gif";
return this.optional(element) || value.match(new RegExp("\\.(" + param + ")$", "i"));
}, "Please upload a file with a valid extension.");
// jQuery Validation
$('#uploadForm').validate({
rules: {
name: {
required: true,
maxlength: 255
},
'images[]': {
required: true,
extension: "jpg|jpeg|png|gif"
}
},
messages: {
name: {
required: "Please enter product name",
maxlength: "Product name cannot exceed 255 characters"
},
'images[]': {
required: "Please upload at least one image",
extension: "Only image files are allowed"
}
},
errorPlacement: function(error, element) {
if (element.attr("name") === "images[]") {
error.appendTo("#imageError");
} else if (element.attr("name") === "name") {
error.appendTo("#nameError");
} else {
error.insertAfter(element);
}
},
submitHandler: function(form) {
if (selectedFiles.length === 0) {
$('#imageError').text('Please upload at least one image.');
return false;
}
form.submit();
}
});
</script>
@endsection
edit.blade.php
Same as create, but shows existing images.
@extends('layouts.app')
@section('title', 'Edit Product')
@section('content')
<div class="container mt-4">
<h2>Edit Product</h2>
<form id="editForm" method="POST" action="{{ route('product.update', $product->id) }}" enctype="multipart/form-data">
@csrf
@method('PUT')
{{-- Product Name --}}
<div class="mb-3">
<label for="name" class="form-label">Product Name</label>
<input type="text" name="name" id="name" class="form-control"
value="{{ old('name', $product->name) }}" required>
<span class="text-danger" id="nameError"></span>
</div>
{{-- Existing Images --}}
<div class="mb-3">
<label class="form-label">Existing Images</label>
<div id="existingImages" class="d-flex flex-wrap gap-2">
@foreach ($product->images as $image)
<div class="image-box" data-id="{{ $image->id }}">
<img src="{{ asset('storage/products/' . $product->id . '/' . $image->image_path) }}"
class="preview-image">
<button type="button" class="remove-btn"
onclick="removeExistingImage({{ $image->id }})">ร</button>
</div>
@endforeach
</div>
</div>
{{-- New Images --}}
<div class="mb-3">
<label for="imageInput" class="form-label">Add More Images</label>
<input type="file" id="imageInput" name="images[]" accept="image/*" multiple class="form-control">
<div id="newImagePreview" class="d-flex flex-wrap gap-2 mt-3"></div>
<span class="text-danger" id="imageError"></span>
</div>
{{-- Hidden field to track removed image IDs --}}
<input type="hidden" name="remove_image_ids" id="remove_image_ids" value="">
<button type="submit" class="btn btn-primary mt-2">Update</button>
</form>
</div>
<style>
.preview-image {
width: 120px;
height: 120px;
object-fit: cover;
margin: 5px;
border: 1px solid #ccc;
position: relative;
border-radius: 8px;
}
.remove-btn {
position: absolute;
top: -5px;
right: -5px;
background: red;
color: white;
border: none;
border-radius: 50%;
width: 20px;
height: 20px;
font-size: 14px;
cursor: pointer;
}
.image-box {
position: relative;
display: inline-block;
}
</style>
@endsection
@section('scripts')
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-validation@1.19.5/dist/jquery.validate.min.js"></script>
<script>
let selectedFiles = [];
let removedImageIds = [];
// Handle new image selection
$('#imageInput').on('change', function(e) {
selectedFiles = Array.from(e.target.files);
displayNewImages();
});
function displayNewImages() {
$('#newImagePreview').empty();
selectedFiles.forEach((file, index) => {
const reader = new FileReader();
reader.onload = function(e) {
const imgBox = `
<div class="image-box">
<img src="${e.target.result}" class="preview-image">
<button type="button" class="remove-btn" onclick="removeNewImage(${index})">ร</button>
</div>
`;
$('#newImagePreview').append(imgBox);
};
reader.readAsDataURL(file);
});
updateInputFiles();
}
function removeNewImage(index) {
selectedFiles.splice(index, 1);
displayNewImages();
}
function updateInputFiles() {
const dataTransfer = new DataTransfer();
selectedFiles.forEach(file => dataTransfer.items.add(file));
document.getElementById('imageInput').files = dataTransfer.files;
}
// Remove existing image (mark for deletion)
function removeExistingImage(id) {
removedImageIds.push(id);
$('#remove_image_ids').val(removedImageIds.join(','));
$(`.image-box[data-id="${id}"]`).remove();
}
// Add extension validator if not included
$.validator.addMethod("extension", function(value, element, param) {
param = typeof param === "string" ? param.replace(/,/g, '|') : "png|jpe?g|gif";
return this.optional(element) || value.match(new RegExp("\\.(" + param + ")$", "i"));
}, "Please upload a file with a valid extension.");
// Validation
$('#editForm').validate({
rules: {
name: {
required: true,
maxlength: 255
},
'images[]': {
extension: "jpg|jpeg|png|gif"
}
},
messages: {
name: {
required: "Please enter product name",
maxlength: "Product name cannot exceed 255 characters"
},
'images[]': {
extension: "Only image files are allowed"
}
},
errorPlacement: function(error, element) {
if (element.attr("name") === "images[]") {
error.appendTo("#imageError");
} else if (element.attr("name") === "name") {
error.appendTo("#nameError");
} else {
error.insertAfter(element);
}
},
submitHandler: function(form) {
const existingCount = $('#existingImages .image-box').length;
const newCount = selectedFiles.length;
if (existingCount + newCount === 0) {
$('#imageError').text('Please upload at least one image.');
return false;
}
form.submit();
}
});
</script>
@endsection
6: Controller Functions (ProductController.php
)
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use App\Models\ProductImage;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
class ProductController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return view('product.index');
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
return view('product.create');
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
// Validate
$request->validate([
'name' => 'required|string|max:255',
'images.*' => 'required|image|mimes:jpg,jpeg,png,gif|max:2048',
]);
// Save product
$product = Product::create([
'name' => $request->name
]);
$folderName = 'products/' . $product->id;
if ($request->hasFile('images')) {
foreach ($request->file('images') as $file) {
$filename = time() . '_' . uniqid() . '.' . $file->getClientOriginalExtension();
// Store in storage/app/public/products/{product_id}/
$file->storeAs('public/' . $folderName, $filename);
// Save filename to DB (not path)
ProductImage::create([
'product_id' => $product->id,
'image_path' => $filename
]);
}
}
return redirect()->route('product.index')->with('success', 'Product created and images uploaded successfully!');
}
/**
* Display the specified resource.
*/
public function show(string $id)
{
//
}
/**
* Show the form for editing the specified resource.
*/
public function edit(string $id)
{
$product = Product::with('images')->findOrFail($id);
return view('product.edit', compact('product'));
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, string $id)
{
// Validate
$request->validate([
'name' => 'required|string|max:255',
'images.*' => 'image|mimes:jpg,jpeg,png,gif|max:2048',
]);
$product = Product::findOrFail($id);
$product->update([
'name' => $request->name
]);
// Handle deleted images
$removeIds = explode(',', $request->remove_image_ids);
foreach ($removeIds as $imgId) {
$img = ProductImage::where('product_id', $product->id)->where('id', $imgId)->first();
if ($img) {
Storage::delete('public/products/' . $product->id . '/' . $img->image_path);
$img->delete();
}
}
// Handle new images
if ($request->hasFile('images')) {
foreach ($request->file('images') as $file) {
$filename = time() . '_' . uniqid() . '.' . $file->getClientOriginalExtension();
$file->storeAs('public/products/' . $product->id, $filename);
ProductImage::create([
'product_id' => $product->id,
'image_path' => $filename
]);
}
}
return redirect()->route('product.index')->with('success', 'Product updated successfully!');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(string $id)
{
$product = Product::findOrFail($id);
$productDirectory = storage_path('app/public/products/' . $id);
if (File::exists($productDirectory)) {
File::deleteDirectory($productDirectory);
}
// Delete records from the gallery_images table
ProductImage::where('product_id', $id)->delete();
// Delete the product
$product->delete();
return response()->json(['success' => true, 'message' => 'Product deleted successfully.']);
}
public function fetch()
{
$product = Product::all();
return response()->json(['data' => $product]);
}
}
Final Step: Run the Laravel App in Browser
php artisan serve
Then open your browser and go to:
๐ Support & Feedback
If you found this Laravel tutorial helpful, please:
โ
Share it with your fellow developers
โ
Comment below if you have questions or suggestions
โ
Follow me for more Laravel tutorials
๐ฌ Got stuck somewhere or have a feature request? Drop a comment โ I reply to everyone!
๐ Stay tuned โ next tutorial: Laravel with Livewire & Image Upload!
Subscribe to my newsletter
Read articles from CodeWithSdCode directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

CodeWithSdCode
CodeWithSdCode
Iโm SdCode, a passionate Laravel developer sharing simple tutorials and practical coding tips to help beginners and intermediate devs grow their skills and build great projects.