Reader Stacks

Laravel CRUD With Search, Image Upload, and Pagination: A Complete Tutorial

A complete resource controller with search, an uploaded image field, and pagination — the pattern behind most admin CRUD screens.

Laravel CRUD With Search, Image Upload, and Pagination: A Complete Tutorial

Most admin CRUD screens follow the same shape: a resource controller, a search box that filters the listing, an image upload on the form, and pagination on the result — building all four together is more useful than seeing them in isolation.

The resource controller and routes

php artisan make:controller ProductController --resource --model=Product
Route::resource('products', ProductController::class);

Route::resource registers all seven conventional routes (index, create, store, show, edit, update, destroy) in one line.

Search in the index method

public function index(Request $request)
{
    $products = Product::query()
        ->when($request->filled('search'), function ($query) use ($request) {
            $query->where('name', 'like', '%'.$request->search.'%');
        })
        ->latest()
        ->paginate(10)
        ->withQueryString();

    return view('products.index', compact('products'));
}

when() only applies the search filter if the query string is actually present, and withQueryString() keeps the search term attached to the pagination links so paging to page 2 doesn't silently drop the active search.

Handling the image upload in store()

public function store(Request $request)
{
    $validated = $request->validate([
        'name' => 'required|string|max:255',
        'price' => 'required|numeric|min:0',
        'image' => 'nullable|image|max:2048',
    ]);

    if ($request->hasFile('image')) {
        $validated['image'] = $request->file('image')->store('products', 'public');
    }

    Product::create($validated);

    return redirect()->route('products.index')->with('success', 'Product created.');
}

Replacing the image on update

public function update(Request $request, Product $product)
{
    $validated = $request->validate([
        'name' => 'required|string|max:255',
        'price' => 'required|numeric|min:0',
        'image' => 'nullable|image|max:2048',
    ]);

    if ($request->hasFile('image')) {
        if ($product->image) {
            Storage::disk('public')->delete($product->image);
        }
        $validated['image'] = $request->file('image')->store('products', 'public');
    }

    $product->update($validated);

    return redirect()->route('products.index')->with('success', 'Product updated.');
}

Deleting the old file before storing the new one prevents orphaned files from accumulating in storage every time a product's image is changed.

The search form and pagination links in the view

@foreach ($products as $product)
{{ $product->name }} — ${{ $product->price }}
@endforeach {{ $products->links() }}

Cleaning up on delete

public function destroy(Product $product)
{
    if ($product->image) {
        Storage::disk('public')->delete($product->image);
    }
    $product->delete();

    return redirect()->route('products.index')->with('success', 'Product deleted.');
}

Deleting the associated image file alongside the database record avoids leaving unused files behind in storage indefinitely.