How to Add Images in Laravel
Learn how to validate, store, display, transform, secure, and test uploaded images in Laravel with local or cloud disks.
To add images in Laravel, validate the uploaded file, store it through a configured filesystem disk, save the returned disk-relative path in your database, and generate a URL through that disk when rendering the image. The path you persist is not automatically a browser URL.
1. Choose the storage design first
Decide whether each image is public or private before writing the upload code.
| Requirement | Recommended design |
|---|---|
| Profile photos, product images, public posts | Public disk and a generated URL |
| Invoices, private documents, user-only images | Private disk plus an authorization-controlled response or temporary URL |
| Single-server development | Local disk |
| Multiple application servers or large media collections | Cloud disk such as S3, configured through Laravel’s filesystem |
Laravel’s filesystem abstraction lets application code use the same storage methods while the underlying disk changes. See the Laravel file storage documentation.
2. Create the model and migration
Store a relative path, not a path assembled from user input and not a hard-coded URL.
php artisan make:model Product -m
php artisan make:controller ProductController
// database/migrations/xxxx_xx_xx_create_products_table.php
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('image_path')->nullable();
$table->timestamps();
});
// app/Models/Product.php
class Product extends Model
{
protected $fillable = ['name', 'image_path'];
}
php artisan migrate
3. Add the upload form
The form must use multipart/form-data; without it, the file will not be present in Laravel’s request.
<form method="POST" action="{{ route('products.store') }}" enctype="multipart/form-data">
@csrf
<label for="name">Name</label>
<input id="name" name="name" value="{{ old('name') }}" required>
<label for="image">Image</label>
<input id="image" name="image" type="file" accept="image/*" required>
@error('image') <p>{{ $message }}</p> @enderror
<button type="submit">Save product</button>
</form>
4. Validate and store the uploaded image
Laravel’s image rule checks that the upload is an image. Add size and dimension limits appropriate to your application. The store method generates a unique filename and returns a path relative to the selected disk.
// routes/web.php
Route::post('/products', [ProductController::class, 'store'])->name('products.store');
// app/Http/Controllers/ProductController.php
use App\Models\Product;
use Illuminate\Http\Request;
public function store(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max: hano255'],
'image' => [
'required',
'image',
'max:5120', // kilobytes: 5 MB
'dimensions:min_width=200,min_height=200,max_width=6000,max_height=6000',
],
]);
$path = $request->file('image')->store('products', 'public');
$product = Product::create([
'name' => $validated['name'],
'image_path' => $path,
]);
return redirect()->route('products.show', $product);
}
Replace max: hano255 with max:255 in your project. The corrected validation line is:
'name' => ['required', 'string', 'max:255'],
Never trust the client filename as a filesystem path. Laravel derives an extension from the detected MIME type and generates a unique stored name.
5. Make locally stored images reachable
Laravel’s local public disk stores files in storage/app/public. Create the symbolic link that exposes that directory through public/storage:
php artisan storage:link
Run this during deployment as well as local setup. The link is only for content intentionally made public.
6. Render the image URL
Generate the URL through the same disk used for storage. Do not prepend /storage blindly when using cloud disks.
<img
src="{{ Storage::disk('public')->url($product->image_path) }}"
alt="{{ $product->name }}"
width="800"
height="600"
>
use Illuminate\Support\Facades\Storage;
$url = Storage::disk('public')->url($product->image_path);
For an S3-style disk, configure the bucket and endpoint in .env; Laravel’s URL method then returns the configured disk URL.
7. Public versus private images
Public image response
Use the public disk only when anyone who knows the URL may view the image.
$path = $request->file('image')->store('avatars', 'public');
$url = Storage::disk('public')->url($path);
Private image response
Keep sensitive files on a private disk and authorize every download.
// config/filesystems.php contains a private disk named 'local'
public function download(Product $product)
{
abort_unless(auth()->user()->can('view', $product), 403);
return Storage::disk('local')->download(
$product->image_path,
basename($product->image_path)
);
}
For object storage, use a temporary URL when your configured driver supports it, and keep authorization in your controller or policy.
8. Resize, crop, or convert images
Basic uploads do not require an image library. Laravel’s optional image API uses Intervention Image. Install it and ensure GD or Imagick is available:
composer require intervention/image:^4.0
A transformation can cover a fixed rectangle, convert formats, and write the result to a disk. Consult Laravel’s Image Manipulation documentation for the driver configuration that matches your environment.
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
public function store(Request $request)
{
$data = $request->validate([
'image' => ['required', 'image', 'max:10240'],
]);
$manager = new ImageManager(new Driver());
$image = $manager->read($data['image'])
->cover(1200, 800)
->toWebp(quality: ಿರ90);
$path = 'products/'.Str::uuid().'.webp';
Storage::disk('public')->put($path, (string) $image);
// Persist $path on your model.
}
Replace quality: 90 if your PHP editor displays a malformed character from copied text. Large transformations can consume substantial CPU and memory; Laravel recommends moving heavy image processing to a queued job instead of doing it during the upload request (official guidance).
9. Validation details and edge cases
- SVG: Laravel 12’s
imagerule no longer accepts SVG by default. Opt in only after deciding how you will sanitize and serve SVG content; SVG can contain active markup. See the Laravel 12 upgrade guide. - Oversized uploads: Check PHP’s
upload_max_filesizeandpost_max_sizeas well as Laravel’s validation limit. - Dimensions: Reject images that are too small for the feature or excessively large for predictable processing.
- Replacement: Store the new file first, update the record, then delete the old path only after the database update succeeds.
- Missing file: Use
hasFile('image')andisValid()when handling optional or lower-level upload flows. - Empty database path: Render a placeholder when
image_pathis null. - Filenames: Keep the generated path; do not allow
../, slashes, or user-controlled directory names.
10. Test uploads without a browser
Laravel’s HTTP testing helpers can fake an image and assert that it exists on the selected disk.
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
public function test_product_image_is_stored(): void
{
Storage::fake('public');
$response = $this->post(route('products.store'), [
'name' => 'Demo product',
'image' => UploadedFile::fake()->image('product.jpg', 800, 600),
]);
$response->assertRedirect();
$product = Product::firstOrFail();
Storage::disk('public')->assertExists($product->image_path);
}
See Laravel’s HTTP testing documentation for additional file assertions.
11. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
image_path is null |
Field name mismatch or missing multipart encoding | Use the same field name in the form and controller and add enctype="multipart/form-data". |
| Image URL returns 404 on local disk | Storage symlink is absent | Run php artisan storage:link and verify permissions. |
| Validation rejects a valid-looking file | It exceeds size or dimensions, or is an unsupported type | Inspect the validation errors and adjust limits deliberately. |
| Cloud URL points to the wrong host | Disk endpoint or bucket URL is misconfigured | Review the selected disk’s environment variables and call its url() method. |
| Allowed upload is rejected as SVG | Laravel 12 changed the default image rule | Opt in to SVG only with a sanitization and delivery plan. |
| Request times out while resizing | Transformation is CPU or memory intensive | Resize asynchronously with a queue and show a processing state. |
| Private image is publicly readable | It was stored on the public disk or exposed by a symlink | Move it to a private disk and serve it through an authorized endpoint. |
12. Performance, reliability, and cost
- Validate dimensions and file size before expensive transformations.
- Use generated names to avoid collisions and unsafe path input.
- For production scale, use object storage so web servers do not hold user uploads locally.
- Queue large conversions and generate thumbnails once; store each derivative path.
- Serve appropriately sized derivatives instead of the original for every page.
- Delete orphaned files when a record is permanently removed, using a retryable cleanup job.
- Keep database transactions and file operations coordinated: if a database write fails after storage, schedule cleanup rather than leaving untracked files.
- Storage cost depends on the disk provider, retained originals, derivatives, bandwidth, and request volume; measure those separately.
Or skip the browser setup
If your Laravel feature also needs screenshots of pages, ScreenshotNeo provides a single request that returns a PNG, JPEG, WebP, or PDF. Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. Its MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf.
See the ScreenshotNeo API documentation for all options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
ScreenshotNeo includes full-page and element capture, device presets, custom CSS and JavaScript, waiting and blocking controls, headers, cookies, geolocation, caching, signed links, asynchronous jobs, bulk capture, and PDF output. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
FAQ
Should I store the image URL or path?
Store the disk-relative path. Generate the URL from the configured disk when you render or deliver the image.
Do I need Intervention Image for uploads?
No. Install it only when you need resizing, cropping, format conversion, or other transformations.
Is the local public disk suitable for private photos?
No. Use a private disk and an authorized download or temporary URL.
Why does my image work locally but fail in production?
Common causes are a missing storage symlink, different disk environment variables, missing object-storage permissions, or server upload limits.


