Reader Stacks

Building a Laravel CRUD App With Search, Image Upload, and Pagination

Combining search, image upload, and pagination in one CRUD app is really three previously-covered patterns composed together, not a genuinely new technique on its own.

Building a Laravel CRUD App With Search, Image Upload, and Pagination

A complete CRUD (Create, Read, Update, Delete) app with search and image upload combines several patterns already covered individually elsewhere on this site — the value here is seeing them composed together into one realistic, complete example.

The migration

Schema::create('products', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->text('description')->nullable();
    $table->decimal('price', 10, 2);
    $table->string('image')->nullable();
    $table->timestamps();
});

The index method: listing with search and pagination

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'));
}

The store method: creating with an image upload

public function store(Request $request)
{
    $validated = $request->validate([
        'name' => 'required|string|max:255',
        'description' => 'nullable|string',
        '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.');
}

The update method: replacing an image and cleaning up the old one

public function update(Request $request, Product $product)
{
    $validated = $request->validate([
        'name' => 'required|string|max:255',
        'description' => 'nullable|string',
        '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 image before storing the new one avoids accumulating orphaned files in storage indefinitely — an easy step to forget, since the update otherwise works correctly even without it, just leaving unused files behind silently.

The destroy method: deleting a product and its image together

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.');
}

The index view, tying search, the image, and pagination together

@foreach ($products as $product)
@if ($product->image) @endif

{{ $product->name }}

{{ $product->price }}

@endforeach {{ $products->links() }}

Why this composed example is worth having as one reference

Each individual piece — search filtering, image upload and cleanup, pagination — is straightforward on its own, but seeing how they interact together (search terms surviving pagination via withQueryString(), an image being deleted before a new one replaces it) is where the real-world subtlety in a genuine CRUD feature actually lives.