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.