Muhammad Yasir Amin

Loading websiteโ€ฆ

More from Muhammad Yasir Amin

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Subscribe on YouTube

Laravel 13 CRUD with Image Upload: Create, Update and Delete Files Safely

๐Ÿ› ๏ธ Laravel Tutorials5 min read

Laravel 13 CRUD with Image Upload: Create, Update and Delete Files Safely

Muhammad Yasir Amin

Muhammad Yasir Amin

Senior Full Stack Engineerโ€ขNov 12, 2026
Laravel 13 CRUD with Image Upload: Create, Update and Delete Files Safely

To add an image upload to a Laravel 13 CRUD, validate the file with the image, mimes and max rules, store it with $request->file('image')->store('products', 'public'), save the returned path in the database, and delete the old file when the record is updated or deleted. This tutorial builds a small product CRUD that handles all of those steps.

Targets: Laravel 13 (PHP 8.3 to 8.5), the public filesystem disk and Blade views.

Step 1: Create the model, migration and controller

Code
php artisan make:model Product -mc --resource --requests
PHP
Schema::create('products', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->decimal('price', 10, 2);
    $table->string('image')->nullable();
    $table->timestamps();
});
PHP
// app/Models/Product.php
protected $fillable = ['name', 'price', 'image'];

Run php artisan migrate. The image column holds the path of the file, not the file itself.

Step 2: Link the public storage

Code
php artisan storage:link

This creates public/storage, which points to storage/app/public, so uploaded files can be opened in the browser.

Step 3: Register the routes

CSS
use App\Http\Controllers\ProductController;
use Illuminate\Support\Facades\Route;

Route::resource('products', ProductController::class);

Step 4: Validate the upload

The generated form requests keep validation out of the controller. Use the same rules for store and update, with the image optional on update:

JavaScript
// app/Http/Requests/StoreProductRequest.php
public function rules(): array
{
    return [
        'name' => ['required', 'string', 'max:255'],
        'price' => ['required', 'numeric', 'min:0'],
        'image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
    ];
}

// app/Http/Requests/UpdateProductRequest.php
public function rules(): array
{
    return [
        'name' => ['required', 'string', 'max:255'],
        'price' => ['required', 'numeric', 'min:0'],
        'image' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
    ];
}

Set authorize() to return true (or your own check) in both classes. max:2048 means 2048 kilobytes. Also check upload_max_filesize and post_max_size in php.ini, because PHP rejects larger files before Laravel sees them.

Step 5: Write the controller

JavaScript
<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreProductRequest;
use App\Http\Requests\UpdateProductRequest;
use App\Models\Product;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;

class ProductController extends Controller
{
    public function index(): View
    {
        return view('products.index', ['products' => Product::latest()->paginate(10)]);
    }

    public function create(): View
    {
        return view('products.create');
    }

    public function store(StoreProductRequest $request): RedirectResponse
    {
        $data = $request->validated();
        $data['image'] = $request->file('image')->store('products', 'public');

        Product::create($data);

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

    public function edit(Product $product): View
    {
        return view('products.edit', compact('product'));
    }

    public function update(UpdateProductRequest $request, Product $product): RedirectResponse
    {
        $data = $request->validated();

        if ($request->hasFile('image')) {
            $newPath = $request->file('image')->store('products', 'public');

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

            $data['image'] = $newPath;
        } else {
            unset($data['image']);
        }

        $product->update($data);

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

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

        $product->delete();

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

Two details matter. On update the new file is stored before the old one is deleted, so a failed upload never leaves a record pointing to a missing file. And store() generates a random file name, which avoids name clashes and removes risky characters from user file names.

Step 6: Build the Blade form

Shell
<form method="POST" action="{{ route('products.store') }}" enctype="multipart/form-data">
    @csrf
    <input type="text" name="name" value="{{ old('name') }}">
    <input type="number" step="0.01" name="price" value="{{ old('price') }}">
    <input type="file" name="image" accept="image/*">
    @error('image') <p>{{ $message }}</p> @enderror
    <button type="submit">Save</button>
</form>

The enctype="multipart/form-data" attribute is required, otherwise no file is sent. For the edit form, use @method('PUT') with the update route, and show the current image above the file input.

Step 7: Show the image in the list

Shell
@foreach ($products as $product)
    <tr>
        <td><img src="{{ Storage::url($product->image) }}" alt="{{ $product->name }}" width="60"></td>
        <td>{{ $product->name }}</td>
        <td>{{ $product->price }}</td>
    </tr>
@endforeach

Storage::url() builds a URL such as /storage/products/abc.jpg for files on the public disk. Import the facade in the view with @php use Illuminate\Support\Facades\Storage; @endphp or use asset('storage/'.$product->image).

How do I check that it works?

  1. Create a product with a small PNG. Check that a file appears in storage/app/public/products.
  2. Edit the product and upload a different image. The old file should be removed and the new one shown.
  3. Delete the product and confirm the file is gone.
  4. Try uploading a PDF or a file above 2 MB and confirm that the validation error appears.

Common mistakes

  • Missing enctype: the request arrives without the file.
  • Forgetting storage:link: images save fine but return 404 in the browser.
  • Not deleting old files: storage fills up with unused images.
  • Trusting the client file name: use store() or a generated name.
  • Validating only the extension: use the image rule, which checks the file contents too. Note that the image rule does not allow SVG by default, because SVG files can contain scripts.

FAQ

Should images be stored in the database?

No. Store the file on disk or in S3 and keep only the path in the database.

How do I use S3 instead of the public disk?

Change 'public' to 's3' in the store and delete calls, and use Storage::disk('s3')->url() to build URLs.

How do I resize images?

Use an image library such as Intervention Image before saving the file. That is covered in a separate tutorial on generating thumbnails.

References

Article Index & Topic Tags
LaravelStorageCrudImage-Upload

YouTube Channel

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Muhammad Yasir Amin
Available for Contracts
Author & Solutions Architect

Senior Full Stack Engineer

Independent Senior Full Stack Engineer working on Laravel, React and Next.js applications, APIs and delivery workflows.

Laravel & PHP ApplicationsReact & Next.js InterfacesREST APIs & IntegrationsDatabase Queries & DebuggingDocker Development WorkflowsClear Scope & Handover
Discuss Your Next Step

What Does Your Application Need Next?

Share your current system, the problem you want to solve and what a useful result looks like. We can identify the right starting point and agree on a practical scope.

Technical Audit

Find the Next Useful Fix

Application Development

Scoped, Reviewable Milestones

Ongoing Support

Agreed Capacity and Priorities

Share your goals, current stack and requirements before the call.