Laravel Image Upload with Preview and Multiple Images CRUD Example

CodeWithSdCodeCodeWithSdCode
8 min read

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:

http://127.0.0.1:8000/product


๐Ÿ™Œ 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!

0
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.