Laravel CRUD with image upload is a powerful and practical feature every developer should master. Building a CRUD (Create, Read, Update, Delete) system is a core part of Laravel development—but making it user-friendly means going beyond the basics. In this guide, we’ll enhance a Laravel CRUD application by adding image upload functionality along with essential features like search, sorting, and pagination.
In this tutorial, you’ll learn how to build a complete Laravel CRUD application from scratch, including how to upload and display images, implement dynamic search and sort capabilities, and paginate results using Eloquent, Blade, and Bootstrap.
Table of Contents
Step 1: Create Laravel Project
Open your Terminal/CMD, go to the project’s root folder, and then run the command below:
composer global require laravel/installerlaravel new laravel-crud-image-uploadStep 2: Create Database Table
We’ll create a database table with fields needed for a Laravel CRUD with image upload, including columns to store filenames and other record data.
Setup database connection by configuring your Laravel project to connect with MySQL or other databases using the .env file — the first step for any Laravel CRUD project.

Then open your Terminal/CMD, go to the project’s root folder, and then run the command below:
php artisan make:migration create_customers_tableUpdate the up() function with content below
public function up(): void
{
Schema::create('customers', function (Blueprint $table) {
$table->id();
$table->string('name', 50);
$table->tinyInteger('gender');
$table->string('email', 100);
$table->string('image')->nullable();
$table->timestamps();
});
}Run the migration:
php artisan migrateDatabase Error Fix: Specified key was too long
If you encounter the following error during a migration (often when running php artisan migrate):
SQLSTATE[42000]: Syntax error or access violation: 1071 Specified key was too long; max key length is 1000 bytes...
![SQLSTATE[42000] 1071 Specified key was too long](https://laravelcenter.com/wp-content/uploads/2025/06/SQLSTATE42000-1071-Specified-key-was-too-long-1024x523.webp)
This issue typically occurs when using an older version of MySQL (pre-5.7.7) or an outdated MariaDB version with Laravel’s default settings.
You need to tell the Laravel database schema builder to use a smaller default string length.
Open the file app/Providers/AppServiceProvider.php. add the following line:
use Illuminate\Support\Facades\Schema;
public function boot(): void
{
// Add this line:
Schema::defaultStringLength(191);
}After saving the file, you can try running your migrations again:
php artisan migrateStep 3: Create Model
The Eloquent model defines the structure for our Laravel CRUD application and helps manage uploaded image paths as part of the Laravel CRUD with image upload.
Create a file named Customer.php in the PROJECT_ROOT/app/Models directory. You can either use an artisan command or create the file manually.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Customer extends Model
{
//
}This model allows Laravel to manage customer records and store image file names, making it a core part of our Laravel CRUD with image upload setup.
Step 4: Create Controller
This controller will power the Laravel CRUD with image upload, handling the logic for storing records, uploading images, and validating user input.
Create a file named CustomerController.php in the PROJECT_ROOT/app/Http/Controllers directory. You can either use an artisan command or create the file manually.
<?php
namespace App\Http\Controllers;
use App\Models\Customer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class CustomerController extends Controller
{
public function index(Request $request)
{
$search = $request->get('search');
$gender = $request->get('gender', 0);
$field = $request->get('field', 'created_at');
$order = $request->get('order', 'desc');
$customers = Customer::when($gender > 0, function ($query) use ($gender) {
$query->where('gender', $gender);
})
->when($search, function ($query) use ($search) {
$query->where('name', 'like', '%' . $search . '%');
})->orderBy($field, $order)->paginate(5)->withQueryString();
return view('customer.index', compact('customers'));
}
public function edit($id)
{
$customer = Customer::find($id);
return view('customer.form', compact('customer'));
}
public function submit(Request $request)
{
// validation
$request->validate([
'name' => 'required|unique:customers,name,' . $request->id,
'gender' => 'required',
'email' => 'required|email:rfc,dns',
]);
// save to database
if ($request->id > 0) {
$data = Customer::find($request->id);
} else {
$data = new Customer();
}
$data->name = $request->name;
$data->gender = $request->gender;
$data->email = $request->email;
// delete uploaded file
if ($request->is_deleted_image == 1 && $request->id > 0) {
if (Storage::disk('public')->exists($data->image)) {
Storage::disk('public')->delete($data->image);
}
$data->image = '';
}
// upload file
else if ($request->hasFile('image')) {
if ($data->image && Storage::disk('public')->exists($data->image)) {
Storage::disk('public')->delete($data->image);
}
$data->image = Storage::disk('public')->put('customer', $request->image);
}
$data->save();
return redirect('/');
}
public function delete(Request $request)
{
$data = Customer::find($request->delete_id);
// delete uploaded file
if ($data->image && Storage::disk('public')->exists($data->image)) {
Storage::disk('public')->delete($data->image);
}
$data->delete();
return redirect('/');
}
}This controller ensures a smooth flow for creating, reading, updating, and deleting records while supporting image file uploads in our Laravel CRUD with image upload project.
Step 5: Create Blade Views
In this tutorial, we’re using Bootstrap to style the CRUD interface, enhancing the appearance of elements such as buttons, forms, and icons. To get started, you’ll need to install Bootstrap and Bootstrap Icons, which Bootstrap relies on for components like dropdowns and tooltips.
Run the following command to install them via npm:
npm install bootstrap bootstrap-iconsAfter installing the necessary packages, you need to import them into your project. Open the resources/js/app.js file and update it as follows:
import './bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.min.css';
import * as bootstrap from 'bootstrap';
window.bootstrap = bootstrap;We’ll build clean Blade templates using Bootstrap to display form fields, uploaded images, and record lists for our Laravel CRUD with image upload.
Create a file named layout.blade.php in the PROJECT_ROOT/resources/views/customer directory. You can either use an artisan command or create the file manually.
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- CSRF Token -->
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ env('APP_NAME') }}</title>
@vite('resources/js/app.js')
@yield('css')
</head>
<body style="display: none;">
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-success">
<div class="container-fluid">
<a class="navbar-brand fw-bold" href="{{ url('/') }}">Laravel Center</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse"
aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</nav>
<main class="container" style="margin-top: 100px;">
@yield('content')
</main>
</body>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.body.style.display = 'block';
});
</script>
@yield('js')
</html>Create a file named index.blade.php in the PROJECT_ROOT/resources/views/customer directory. You can either use an artisan command or create the file manually.
@extends('customer.layout')
@section('content')
<div class="fade modal" tabindex="-1" id="confirm-delete">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure want to delete?</p>
</div>
<div class="modal-footer">
<form id="frm_delete" action="{{ url('/delete') }}" method="post"
style="padding-bottom: 0px;margin-bottom: 0px;">
@method('DELETE')
@csrf
<input type="hidden" name="delete_id" id="delete_id" />
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger" data-bs-dismiss="modal"
id="delete_btn">Delete</button>
</form>
</div>
</div>
</div>
</div>
<div class="container">
<div class="pagetitle">
<a type="button" class="btn btn-primary" style="float: right" href="{{ url('/create') }}">
<i class="bi bi-plus-circle"></i> Add New
</a>
<h2>Customers List</h2>
<hr />
</div>
<form method="get" id="search_form">
<div class="row pb-3">
<div class="col-md-10">
<div class="row justify-content-start">
<div class="col-lg-3 col-sm-6">
<label class="form-label mb-0">Name</label>
<input type="text" class="form-control" id="search" value="{{ request('search') }}"
placeholder="Search..." />
</div>
<div class="col-lg-3 col-sm-6">
<label class="form-label mb-0">Gender</label>
<select class="form-select" id="gender">
<option value="0" {{ request('gender') == 0 ? 'selected' : '' }}>ALL</option>
<option value="2" {{ request('gender') == 2 ? 'selected' : '' }}>Male</option>
<option value="1" {{ request('gender') == 1 ? 'selected' : '' }}>Female</option>
</select>
</div>
</div>
</div>
<div class="col-md-2 align-self-end">
<button type="submit" class="btn btn-secondary pt-1" style="float: right">
<i class="bi bi-search"></i> Search
</button>
</div>
</div>
</form>
<table class="table table-striped">
<thead class="table-dark">
<tr>
<th width="60px" class="text-center">No</th>
<th width="150px" class="text-center">Photo</th>
<th style="cursor: pointer" onclick="loadData('name')">
Name
<i
class="text-secondary {{ request('field') == 'name'
? (request('order') == 'desc'
? 'bi bi-sort-alpha-down-alt'
: 'bi bi-sort-alpha-down')
: 'bi bi-arrow-down-up' }}"></i>
</th>
<th style="cursor: pointer" onclick="loadData('gender')">
Gender
<i
class="text-secondary {{ request('field') == 'gender'
? (request('order') == 'desc'
? 'bi bi-sort-alpha-down-alt'
: 'bi bi-sort-alpha-down')
: 'bi bi-arrow-down-up' }}"></i>
</th>
<th style="cursor: pointer" onclick="loadData('email')">
Email
<i
class="text-secondary {{ request('field') == 'email'
? (request('order') == 'desc'
? 'bi bi-sort-alpha-down-alt'
: 'bi bi-sort-alpha-down')
: 'bi bi-arrow-down-up' }}"></i>
</th>
<th style="cursor: pointer" onclick="loadData('created_at')">
Created at
<i
class="text-secondary {{ request('field') == 'created_at'
? (request('order') == 'desc'
? 'bi bi-sort-alpha-down-alt'
: 'bi bi-sort-alpha-down')
: 'bi bi-arrow-down-up' }}"></i>
</th>
<th width="150px" style="vertical-align: middle">Action</th>
</tr>
</thead>
<tbody>
@foreach ($customers as $index => $value)
<tr>
<th style="vertical-align: middle;text-align: center">
{{ $customers->perPage() * ($customers->currentPage() - 1) + ($index + 1) }}
</th>
<td class="text-center py-2"><img style="width: 60px;height: 60px;"
src="{{ url($value->image ? './storage/' . $value->image : './default.png') }}" />
</td>
<td style="vertical-align: middle">{{ $value->name }}</td>
<td style="vertical-align: middle">{{ $value->gender == 2 ? 'Male' : 'Female' }}</td>
<td style="vertical-align: middle">{{ $value->email }}</td>
<td style="vertical-align: middle">{{ date('d/m/Y H:i:s', strtotime($value->created_at)) }}</td>
<td style="vertical-align: middle;text-align: center;">
<i class="bi bi-trash3-fill text-danger" role="button" data-record-id="{{ $value->id }}"
title="Delete" data-bs-toggle="modal" data-bs-target="#confirm-delete"></i>
<a title="Edit" href="{{ url('/edit/' . $value->id) }}">
<i class="bi bi-pencil-square text-success px-3" role="button"></i>
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
<nav>
<ul class="pagination d-flex justify-content-end">
{{ $customers->links() }}
</ul>
</nav>
</div>
@endsection
@section('js')
<script>
document.addEventListener('DOMContentLoaded', function() {
const deleteModal = document.getElementById('confirm-delete');
if (deleteModal) {
deleteModal.addEventListener('show.bs.modal', event => {
const triggerElement = event.relatedTarget;
const recordId = triggerElement.getAttribute('data-record-id');
document.querySelector('input#delete_id').value = recordId;
});
}
});
document.getElementById('search_form').addEventListener('submit', function(event) {
event.preventDefault();
loadData();
});
function loadData(sortBy = null) {
const urlParams = new URLSearchParams(window.location.search);
let field = urlParams.get('field') || 'created_at';
let order = urlParams.get('order') || 'asc';
// Apply sorting logic
if (sortBy) {
if (field === sortBy) {
order = (order === 'asc') ? 'desc' : 'asc';
} else {
field = sortBy;
order = 'asc';
}
}
// Build and submit form
const form = document.createElement('form');
form.method = 'GET';
form.action = '/';
const params = {
search: document.getElementById('search')?.value || '',
gender: document.getElementById('gender')?.value || '',
field: field,
order: order
};
for (const key in params) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = params[key];
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
}
</script>
@endsectionCreate a file named form.blade.php in the PROJECT_ROOT/resources/views/customer directory. You can either use an artisan command or create the file manually.
@extends('customer.layout')
@section('css')
<style>
.required label:after {
content: " *";
color: red;
font-weight: bold;
}
.form-label {
margin-bottom: 0px !important;
}
</style>
@endsection
@section('content')
<div class="fade modal" tabindex="-1" id="alert_modal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Alert</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Ok</button>
</form>
</div>
</div>
</div>
</div>
<div class="container">
<div class="col-md-8 offset-md-2">
<h1>{{ isset($customer) ? 'Edit' : 'New' }} Customer</h1>
<hr />
<form method="POST" enctype="multipart/form-data" action="{{ url('/submit') }}">
@csrf
@method(isset($customer) ? 'PUT' : 'POST')
<input type="hidden" value="{{ isset($customer) ? $customer->id : 0 }}" name="id" />
<div class="row">
<div class="col-md-6">
<div class="required mb-3">
<label for="name" class="form-label">Name</label>
<input id="name" name="name" type="text" value="{{ old('name', isset($customer) ? $customer->name : '') }}"
class="form-control @error('name') is-invalid @enderror" />
@error('name')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="required mb-3">
<label for="gender" class="form-label">Gender</label>
<select id="gender" name="gender" class="form-select @error('gender') is-invalid @enderror">
<option value="2" @if (old('gender', isset($customer) ? $customer->gender : '') == 2) selected @endif>Male</option>
<option value="1" @if (old('gender', isset($customer) ? $customer->gender : '') == 1) selected @endif>Female</option>
</select>
@error('gender')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="required mb-3">
<label for="email" class="form-label">Email</label>
<input id="email" name="email" type="text"
value="{{ old('email', isset($customer) ? $customer->email : '') }}"
class="form-control @error('email') is-invalid @enderror">
@error('email')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<div class="col-md-6">
<div class="mb-3 text-center">
<label class="form-label">Photo</label>
<div>
<input type="hidden" style="display: none" value="0" name="is_deleted_image"
id="is_deleted_image">
<img id="img_preview" style="width: 180px;height: 180px;cursor: pointer;"
class="img img-thumbnail" onerror="this.onerror=null;this.src='./default.png'"
src="{{ isset($customer) && $customer->image ? url('./storage/' . $customer->image) : url('./default.png') }}"
onclick="changeProfile()" />
<p>
<a href="javascript:changeProfile()" style="text-decoration: none;">
Change</a>
<a href="javascript:removeProfile()"
style="color: red;text-decoration: none;">Remove</a>
</p>
<input type="file" id="image" name="image" style="display: none;"
accept=".jpg,.jpeg,.bmp,.gif,.png,.webp" />
</div>
</div>
</div>
</div>
<div class="mt-4 text-center">
<a href="{{ url('/') }}" class="btn btn-danger mx-2">
<i class="bi bi-x-lg"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-floppy" style="padding-right: 3px;"></i> Save
</button>
</div>
</form>
</div>
</div>
@endsection
@section('js')
<script>
function changeProfile() {
document.getElementById('image').click();
}
function validateFile(inputElement, maxFileSize = 1, validFileExtension = [".jpg", ".jpeg", ".bmp", ".gif", ".png",
".webp"
]) {
let image = null;
const files = inputElement.files;
if (files && files.length > 0) {
const alertModal = document.getElementById("alert_modal");
const modal = new bootstrap.Modal(alertModal);
const fileSizeMB = ((files[0].size / 1024) / 1024).toFixed(4);
const fileName = files[0].name;
if (fileSizeMB <= maxFileSize) {
let isValidExtension = false;
let fileExtension = '';
for (let ext of
validFileExtension) {
if (fileName.toLowerCase().endsWith(ext.toLowerCase())) {
isValidExtension = true;
fileExtension = ext;
break;
}
}
const baseName = fileName.substring(0, fileName.length -
fileExtension.length).trim();
const cleanedBaseName = baseName.replace(/[^a-zA-Z0-9-_\s]/g, '');
if (cleanedBaseName !== baseName) {
alertModal.querySelector('.modal-body p').textContent =
"Invalid filename. Filename only allows alphanumeric characters.";
modal.show();
inputElement.value = '';
} else if (isValidExtension) {
image = files[0];
} else {
alertModal.querySelector('.modal-body p').textContent = fileName +
" is invalid. Allowed extensions are: " + validFileExtension.join(", ");
modal.show();
inputElement.value = '';
}
} else {
const maxSizeMsg = (maxFileSize < 1) ?
"Maximum file size is " + (maxFileSize * 1000).toString() + " KB." :
"Maximum file size is " +
maxFileSize.toString() + "MB.";
alertModal.querySelector('.modal-body p').textContent = maxSizeMsg;
modal.show();
inputElement.value = '';
}
}
return image;
}
function removeProfile() {
document.getElementById('img_preview').setAttribute('src', '{{ url("./default.png") }}');
document.getElementById('is_deleted_image').value = 1;
}
document.getElementById('image').addEventListener('change', function() {
if (this.value !== '') {
const
selectedFile = validateFile(this, 1, [".jpg", ".jpeg", ".bmp", ".gif", ".png",
".webp"
]);
if (selectedFile) {
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('img_preview').setAttribute('src', e.target.result);
}
reader.readAsDataURL(selectedFile);
}
}
});
document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() {
document.getElementById('name').focus();
}, 10); // Small delay to ensure element is rendered
});
</script>
@endsectionThese views allow users to interact with the system visually, upload images, and view data efficiently — all part of our Laravel CRUD with image upload guide.
Step 6: Define Routes
Define Laravel routes for all CRUD operations, including endpoints to upload images, which are essential to the Laravel CRUD with image upload flow.
In routes/web.php:
<?php
use App\Http\Controllers\CustomerController;
use Illuminate\Support\Facades\Route;
Route::controller(CustomerController::class)->group(function () {
Route::get('/', 'index');
Route::view('create', 'customer/form');
Route::get('edit/{id}', 'edit');
Route::match(['post', 'put'], 'submit', 'submit');
Route::delete('delete', 'delete');
});These routes connect our frontend and backend actions, completing the request flow for the full Laravel CRUD with image upload functionality.
Additional Setup
💡 Tip: To ensure the project loads smoothly without broken images, you can download this default image and place it inside your Laravel project’s
public/folder. This helps prevent errors when no uploaded images are available yet.
Fixing Pagination Styling with Bootstrap in Laravel
By default, Laravel uses Tailwind CSS for pagination views. If your project is using Bootstrap (especially Bootstrap 4 or 5), the pagination links generated by {{ $items->links() }} may look unstyled or broken.
To fix this, you need to tell Laravel to use Bootstrap-compatible pagination views. You can do this by updating the AppServiceProvider.
Open the file:
app/Providers/AppServiceProvider.php
Inside the boot() method, add the following line:
use Illuminate\Pagination\Paginator;
public function boot()
{
Paginator::useBootstrap();
}This tells Laravel to render pagination links using Bootstrap’s markup instead of Tailwind CSS.
Set Up Laravel Storage
When you’re handling image uploads in Laravel, the uploaded files are typically stored in the storage/app/public directory. However, these files need to be publicly accessible from the browser. Laravel provides a convenient command to create a symbolic link between the public/storage directory and storage/app/public:
php artisan storage:linkThis command will generate a symbolic link so you can access uploaded images using a public URL like /storage/your-image.jpg.
Running and Testing Your Project
Open your Terminal/CMD in separate windows, go to the project’s root folder, and then run the command below:
npm run devphp artisan serveWith both commands running in their separate windows, open your web browser to the Laravel address (http://127.0.0.1:8000). You should now see the customer list with image upload, search, sort, and pagination features working as expected.



By following this guide, you now have a solid understanding of building a full-featured Laravel CRUD with image upload, search, sort, and pagination.
Conclusion
Building a Laravel CRUD with image upload feature is one of the most practical skills every developer should master. It combines the essentials of creating, reading, updating, and deleting data while handling file uploads efficiently. With Laravel’s built-in validation, file storage system, and Eloquent ORM, you can easily create a secure and powerful CRUD application that supports image handling.
By following this tutorial, you’ve learned how to implement image uploads seamlessly within your CRUD operations — from form creation and validation to saving and displaying images. Whether you’re building a blog, product catalog, or user profile system, this approach gives you a clean and scalable foundation to build on.
Now that you’ve mastered Laravel CRUD with image upload, try extending it with additional features like image resizing, multiple image uploads, or drag-and-drop functionality to make your application even more dynamic and user-friendly.







