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.