All Posts

Your image uploads need a `Content-Type` header

September 1st, 2026 2 min read

S3 can infer an uploaded image's content type, but getting it wrong can leave a broken response cached at the edge. Here is how I explicitly map validated image extensions to MIME types in Laravel, including Livewire and Nova uploads.

When storing image uploads on S3-compatible object storage, I generally expect the resulting object to be served with the right Content-Type header.

A PNG should use image/png, an SVG should use image/svg+xml, etc.

Most of the time, that's exactly what happens. Laravel's filesystem layer and the underlying S3 adapter will attempt to infer the type while writing the file. Most of the time is not quite good enough when the object is public and will be used in an <img> tag, though.

I ran into this recently with an image upload that made it to Laravel Cloud's object storage, but was served without a content type header. The URL worked, the object existed, but the browser would not render it as an image.

Even worse, once the response made its way through Laravel Cloud's edge cache and Cloudflare beyond that, fixing the object afterwards may not immediately help. The cache has already seen the object without a Content-Type header.

The fix is to set the content type when the object is first written to object storage.

A file extension is not validation

Whilst it's tempting to look at a file named logo.svg, set its content type to image/svg+xml, and call it a day. That only controls the metadata S3 stores alongside the object. It does not tell you whether the file is actually an SVG, whether it is safe to accept, or whether it is a valid image at all.

We still needs normal server-side validation, and for an image field that accepts SVGs, I might have something like:

'logo' => [
    'nullable',
    'image:allow_svg',
    'max:2048',
],

The content-type mapping comes after that validation. This is about telling object storage how to serve an accepted file, rather than deciding whether the file should be accepted in the first place.

Mapping extensions explicitly

Rather than relying on every storage adapter to correctly infer every image type (and doing it consistently), I have a small enum that maps the extensions I accept to their corresponding MIME types.

<?php

declare(strict_types=1);

namespace App\Enum;

use Illuminate\Support\Str;

enum ImageMimeType: string
{
    // ...
    case Jpeg = 'image/jpeg';
    case Png = 'image/png';
    case Svg = 'image/svg+xml';
    // ...

    public static function fromExtension(string $extension): ?self
    {
        return match (Str::lower($extension)) {
            // ...
            'jpg', 'jpeg' => self::Jpeg,
            'png' => self::Png,
            'svg' => self::Svg,
            // ...
            default => null,
        };
    }
}

The mapping should match the types your application usually accepts. That means there's no need to maintain an list of every image MIME type if your validation only allows PNG, JPGG, WebP, and SVG.

Importantly the mapping is explicit. An SVG should not depend on a MIME detector deciding it looks enough like an SVG.

Storing an upload with Livewire

Livewire's temporary uploads are uploaded-file objects, so Laravel's normal storage API is available.

use App\Enum\ImageMimeType;

$path = $this->form->logo->storePublicly(
    path: 'images',
    options: array_filter([
        'disk' => 's3',
        'ContentType' => ImageMimeType::fromExtension(
            $this->form->logo->getClientOriginalExtension()
        )?->value,
    ]),
);

The ContentType option is the important one here.

Laravel passes that through to Flysystem, and the S3 adapter passes it to the object upload request. The image is created with the correct metadata from the start, before anything has a chance to request and cache it.

The same approach works when the disk is named something more application-specific:

$path = $this->form->logo->storePublicly(
    path: 'images',
    options: [
        'disk' => 'public', 
        'ContentType' => ImageMimeType::fromExtension(
            $this->form->logo->getClientOriginalExtension()
        )?->value,
    ],
);

Whether public is AWS S3, Laravel Cloud object storage, or another S3-compatible provider doesn't really matter here. ContentType is object metadata understood by any S3-compatible object storage.

Customising Nova image uploads

Nova's Image field stores uploaded files for you by default, but it also gives you a store callback when you need control over the process.

That makes it possible to use the exact same mapping as a Livewire upload.

use App\Enum\ImageMimeType;
use Laravel\Nova\Fields\Image;
use Laravel\Nova\Http\Requests\NovaRequest;

Image::make('Logo')
    ->disk('s3')
    ->store(function ( 
        NovaRequest $request,
        $model,
        string $attribute,
        string $requestAttribute,
        ?string $disk,
        ?string $storagePath,
    ): array {
        $file = $request->file($requestAttribute); 

        return [
            $attribute => $file->storePublicly(
                path: $storagePath ?: '/',
                options: array_filter([
                    'disk' => $disk,
                    'ContentType' => ImageMimeType::fromExtension(
                        $file->getClientOriginalExtension()
                    )?->value,
                ]),
            ),
        ]; 
    }),

Nova expects the callback to return the model attributes it should set once storage has completed. Returning the generated path under $attribute preserves the normal behaviour, while taking control of object metadata.

This is preferable to allowing Nova to upload the image first, then opening the stored object and writing it back to the same key with a new ContentType.

What not wait till after?

You can repair an existing object by reading it and writing it back with explicit metadata:

$disk = Storage::disk('public');

$disk->put(
    $path,
    $disk->get($path),
    [
        'visibility' => 'public',
        'ContentType' => ImageMimeType::fromExtension(
            pathinfo($path, PATHINFO_EXTENSION)
        )?->value,
    ],
);

This is useful for a backfill or a one-off repair, but it's the wrong approach for a new upload.

Not only do we have two writes for every file, it also creates a window where an object exists with the wrong metadata. If a browser, CDN, or image proxy requests the object in that window, it may cache the broken response before the rewrite happens. Setting the content type as part of the initial upload removes that window entirely.

What did we learn?

S3-compatible object storage will often infer a content type for you, which is useful but not always reliable. It shouldn't be the only thing standing between an uploaded image and a broken <img> tag.

  • Validate the upload as an image.
  • Map the accepted extension to the content type you expect.
  • Pass that value as ContentType when the object is first stored.

It's a tiny addition to the upload path, but it means the first response from object storage is the correct one.

Share this article
M

Written by Michael Dyrynda

Principal Engineer, Laravel enthusiast, and open source contributor. I write about web development, PHP, and the problems I solve along the way.