A basic Laravel CRUD tutorial usually stops at plain create/read/update/delete — a real admin interface almost always also needs search, pagination, and image upload handled correctly. This walks through all four together, as one connected resource controller.
1. The migration and model
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();
});
2. 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(15)
->withQueryString(); // preserves ?search=... across pagination links
return view('products.index', compact('products'));
}
withQueryString() is the detail most tutorials skip and then wonder why the search term disappears when clicking to page 2 — without it, pagination links only carry the page parameter, silently dropping search and any other query string values from the current request.
3. The search input and pagination links in the view
<form method="GET">
<input type="text" name="search" value="{{ request('search') }}">
<button type="submit">Search</button>
</form>
@foreach ($products as $product)
{{ $product->name }}
@endforeach
{{ $products->links() }}
4. Storing a new record with an image
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', // max size in kilobytes
]);
if ($request->hasFile('image')) {
$validated['image'] = $request->file('image')->store('products', 'public');
}
Product::create($validated);
return redirect()->route('products.index')->with('success', 'Product created.');
}
store('products', 'public') saves the file under storage/app/public/products and returns the relative path — this requires php artisan storage:link to have been run once, which symlinks storage/app/public to public/storage so the uploaded files are actually reachable over HTTP.
5. Updating a record, including replacing its image
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); // clean up the old file
}
$validated['image'] = $request->file('image')->store('products', 'public');
}
$product->update($validated);
return redirect()->route('products.index')->with('success', 'Product updated.');
}
Deleting the old image file before saving the new path is a step that's easy to forget — without it, replaced images just accumulate as orphaned files in storage indefinitely, never referenced by any record but never cleaned up either.
6. Deleting a record (and its image)
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 same cleanup principle applies on delete — removing the database record without also removing its associated file leaves that file orphaned in storage permanently.