Every iPhone sold in the last several years shoots HEIC by default. The file is roughly half the size of the equivalent JPEG at the same quality, which is why Apple switched, and it is also a format that Chrome and Firefox will not render. An app that accepts photo uploads from phones has to deal with this somewhere, and until recently Laravel's image component was not the place: HEIC files were rejected before they reached the driver.
Laravel 13.24 accepts HEIC, HEIF, and AVIF as inputs, adds toHeic() for output, and teaches the image validation rule about all three. This article walks through accepting a phone photo and serving it in a format browsers understand.
What Your Server Needs
HEIC decoding is not something PHP does on its own. It comes from ImageMagick's HEIF delegate, which is built on libheif, so you need the Imagick extension with that delegate compiled in. The GD driver cannot read HEIC at all, no matter which PHP version you are on.
Many distribution packages ship ImageMagick without the HEIF delegate, so check rather than assume:
php -r "print_r(Imagick::queryFormats('HEI*'));"
An empty array means the extension is installed but cannot touch these files. On Debian and Ubuntu the delegate lives in libheif1, and on macOS the Homebrew imagemagick formula includes it. AVIF is more forgiving, since GD can decode it too when PHP was built against libavif.
Install Intervention Image if you have not already, since it backs both drivers:
composer require intervention/image:^4.0
Validating the Upload
The image rule checks the file against a fixed list of image types, and that list now includes heic, heif, and avif:
$request->validate([ 'photo' => ['required', 'image', 'max:12288'],]);
Nothing to configure. A photo taken on an iPhone and uploaded straight from the camera roll passes where it previously failed with "The photo field must be an image."
If you would rather be explicit about what you accept, the mimes rule takes the same extensions:
'photo' => ['required', 'mimes:jpg,png,webp,heic', 'max:12288'],
Note that HEIC uploads arrive with a variety of MIME types depending on the client, image/heic and image/heif being the common pair. The image and mimes rules resolve the type from the file contents rather than trusting the browser, so both land in the same place.
Converting on Upload
Accepting the file is only half of it. Store a HEIC as-is and most of your visitors get a broken image. The fix is to convert during the upload request, which is a couple of lines with the image API:
use App\Models\Photo;use Illuminate\Http\Request; public function store(Request $request){ $request->validate([ 'photo' => ['required', 'image', 'max:12288'], ]); $path = $request->image('photo') ->usingImagick() ->orient() ->scale(width: 2000) ->toWebp() ->quality(80) ->store('photos'); return Photo::create(['path' => $path]);}
Two details worth calling out. usingImagick() is there because the default driver is GD, which will fail on a HEIC input. And orient() reads the orientation metadata and rotates accordingly, which matters more for phone photos than for anything else you will handle, since a portrait shot is usually stored as a landscape frame with a rotation flag.
The stored file gets the right extension automatically. store() builds a hashed filename from the output format, so a HEIC upload converted to WebP lands as photos/{hash}.webp.
Serving AVIF With a WebP Fallback
If you want the smaller file where it is supported, generate both variants off one source. Every transformation returns a new instance, so branching does not leak state between the two:
$source = $request->image('photo')->usingImagick()->orient()->scale(width: 2000); $avif = $source->toAvif()->quality(70)->storeAs('photos', "{$id}.avif");$webp = $source->toWebp()->quality(80)->storeAs('photos', "{$id}.webp");
Then let the browser pick:
<picture> <source srcset="{{ Storage::url("photos/{$photo->id}.avif") }}" type="image/avif"> <img src="{{ Storage::url("photos/{$photo->id}.webp") }}" alt="{{ $photo->caption }}"></picture>
AVIF typically lands 20 to 30 percent smaller than WebP at visually comparable quality, at the cost of slower encoding. If your uploads are synchronous, that encode time shows up in the request, so this is a good candidate for a queued job once you are doing more than one size.
Writing HEIC
Output works too, through toHeic() or optimize('heic'):
Image::fromPath(storage_path('app/photo.jpg')) ->usingImagick() ->toHeic() ->quality(80) ->store('photos');
This is a narrower use case than reading HEIC, but it comes up: an archive or export that will be opened on Apple devices, or a pipeline that keeps the original format on the way through.
The heif alias is normalized to HEIC everywhere, so optimize('heif') produces the same output as optimize('heic'), files are stored with the canonical .heic extension, and mimeType() reports image/heic. Image::extension() also recognizes the image/x-heic and image/x-avif MIME aliases that some clients send, mapping them to heic and avif rather than falling through.
When the Format Is Not Supported
If a file makes it to the driver in a format the component does not handle, you get an ImageException with the offending type in the message:
The image format [image/tiff] is not supported.
Validating first with the image rule catches nearly all of these, but the exception is also what you will see when a HEIC file reaches an Imagick build without the HEIF delegate. That is a deployment problem rather than a user problem, so it is worth checking the delegate on the server as part of a release rather than discovering it from an error report.
For anything older than 13.24, or when you need the conversion outside of Laravel, converting HEIC to JPEG in PHP covers the manual approach.
Further Reading
- A Practical Guide to Laravel's First-Party Image Processing covers the full transformation and storage API
- Image Dominant Color in Laravel pairs well with this, since a converted photo can carry a placeholder color for free
- Image Dominant Color and HEIC Support in Laravel 13.24 has the full release notes
- HEIC and AVIF support was contributed by @riasvdv in #60922