Skip to content
Bladewell

<x-widget.image-cropper>

Image cropper Blade component for Laravel and Livewire

Crop an image before it's sent: drag, pinch or use the keyboard to move and zoom it under a square, round or 16:9 frame, and turn it a quarter at a time. Linked to a file input or file upload, it hands over the cropped file in place of the picked one, upright whatever the photo's EXIF orientation, scaled to a maximum size and without its EXIF data, so upload-url and Livewire only ever send the crop. Or, given a stored image, it sends the crop's coordinates for the server to cut.

php artisan bladewell:add image-cropper
Also adds
Button, Icon
Works with
Laravel 12 and 13, Livewire 3 and 4, Tailwind CSS 4.1+, PHP 8.3+

Usage

Crop on the server

With src, the crop arrives as numbers in the pixels of the image as shown, once it's upright (as its EXIF orientation says) and turned clockwise by rotate. Cut the stored file the same way: upright, then turned, then cropped, scaled by its own size over image_width, in case src was a resized copy. GD ignores EXIF, so the orientation is read with PHP's exif extension, which reads it from JPEG only: browsers also turn a WebP, PNG or HEIC by its EXIF, so store src as JPEG, or upright already. The crop is a new JPEG with no EXIF: no location, no date taken.

PHP
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;

// 65,535 pixels is the most a JPEG, and GD, can be across: anything past it is no crop of a real image.
$crop = $request->validate([
    'crop.rotate' => ['required', 'integer', Rule::in([0, 90, 180, 270])],
    'crop.x' => ['required', 'integer', 'min:0', 'max:65535'],
    'crop.y' => ['required', 'integer', 'min:0', 'max:65535'],
    'crop.width' => ['required', 'integer', 'min:1', 'max:65535'],
    'crop.height' => ['required', 'integer', 'min:1', 'max:65535'],
    'crop.image_width' => ['required', 'integer', 'min:1', 'max:65535'],
    'crop.image_height' => ['required', 'integer', 'min:1', 'max:65535'],
])['crop'];

$path = Storage::disk('local')->path($photo->path);
// Check the size before decoding: a small file can open to a huge image and run out of memory. GD takes 4 bytes a
// pixel, twice over while it turns one: 25 megapixels needs a memory_limit of about 256M. Raise both together.
[$width, $height] = getimagesize($path) ?: [0, 0];
abort_if($width * $height === 0 || $width * $height > 25_000_000, 422, 'That image is too large to crop.');

$image = imagecreatefromstring((string) file_get_contents($path));
// A format getimagesize knows and GD can't open (HEIC), or a damaged file.
abort_if($image === false, 422, 'That image can\'t be cropped.');

// Upright first. imagerotate turns counterclockwise; the mirrored orientations are rare, from some scanners and apps.
$orientation = function_exists('exif_read_data') ? (@exif_read_data($path)['Orientation'] ?? 1) : 1;
$image = match ($orientation) {
    3, 4 => imagerotate($image, 180, 0),
    5, 6 => imagerotate($image, -90, 0),
    7, 8 => imagerotate($image, 90, 0),
    default => $image,
};
if (in_array($orientation, [2, 4, 5, 7], true)) {
    imageflip($image, IMG_FLIP_HORIZONTAL);
}

// The stored file can be larger than what was shown (a thumbnail URL): scale the crop by the upright widths. Its shape
// must match what was shown, or the crop was made on a picture turned differently (a copy whose EXIF was dropped).
$shown = $crop['image_width'] / $crop['image_height'];
abort_if(abs(imagesx($image) / imagesy($image) - $shown) > 0.03 * $shown, 422, 'The crop was made on a different picture.');
$scale = imagesx($image) / $crop['image_width'];

// Then turned as the person turned it, clockwise, and cut, within the image whatever was sent.
$image = (int) $crop['rotate'] === 0 ? $image : imagerotate($image, -(int) $crop['rotate'], 0);
$x = min((int) round($crop['x'] * $scale), imagesx($image) - 1);
$y = min((int) round($crop['y'] * $scale), imagesy($image) - 1);
$image = imagecrop($image, [
    'x' => $x,
    'y' => $y,
    'width' => min((int) round($crop['width'] * $scale), imagesx($image) - $x),
    'height' => min((int) round($crop['height'] * $scale), imagesy($image) - $y),
]);
abort_if($image === false, 422, 'That image can\'t be cropped.');

// JPEG has no transparency: what's see-through goes on white, as the browser's crop does, not black.
$flat = imagecreatetruecolor(imagesx($image), imagesy($image));
imagefill($flat, 0, 0, (int) imagecolorallocate($flat, 255, 255, 255));
imagecopy($flat, $image, 0, 0, 0, 0, imagesx($image), imagesy($image));
$image = $flat;

// A new file, never over the original, so the crop can be changed later.
ob_start();
imagejpeg($image, null, 85);
Storage::disk('local')->put($cropped = "photos/{$photo->id}-cropped.jpg", (string) ob_get_clean());

Livewire

In a Livewire component, bind the file upload with wire:model as usual: the picked photo opens in the cropper first, and only the cropped one is uploaded to the property. $this->photo is that file, ready for its validation rules and store().

Blade
<x-widget.file-upload.image id="photo" wire:model="photo" label="Profile photo" accept="image/jpeg,image/png,image/webp" max-size="10MB" />
<x-widget.image-cropper for="photo" label="Crop your photo" shape="circle" :max-width="512" :max-height="512" />

On the server

The image cropper can't stop a request that skips it. Here's what to check on your server, and what to keep cheap.

Security

  • The cropped file is made in the browser, so a request can send any file instead. Validate it as an image on the server (image, mimes:, dimensions:) and re-encode it before storing, which also drops anything hidden in it.

    OWASP: File upload · Laravel: The dimensions rule

Server cost

  • Cropping on the server opens the whole image in memory: about 4 bytes a pixel, so 190 MB for a 48-megapixel photo. Check its size with getimagesize() before decoding and refuse what's too large, and crop in a queued job if it's slow.

    PHP: getimagesize · Laravel: Queues

Examples

These examples also use File upload, which Image cropper doesn't need on its own. To use them as they are:

Terminal
php artisan bladewell:add image-cropper file-upload

Avatar

A profile photo, cropped square before it's sent. Picking one opens it here instead of sending it; Apply puts the cropped photo in the file upload, which previews and sends that one. A phone photo stored sideways comes in upright, and the cropped file leaves its EXIF data, location included, behind. The preview needs img-src blob: in a Content Security Policy.

Profile photo

JPEG, PNG or WEBP, up to 10 MB

Show code
Blade
<form method="POST" enctype="multipart/form-data" class="grid gap-6">
    @csrf
    <x-widget.file-upload.image id="profile-photo" name="profile_photo" label="Profile photo" accept="image/jpeg,image/png,image/webp" max-size="10MB" />
    <x-widget.image-cropper for="profile-photo" label="Crop your photo" shape="circle" :min-width="200" :max-width="512" :max-height="512" />
</form>

Cover image

A wide banner from a drop zone: aspect="16/9", made at most 1920 × 1080 and as webp, so a 48-megapixel phone photo goes up as a few hundred kilobytes. With upload-url on the file upload, only the cropped image is uploaded.

Images, up to 25 MB

Show code
Blade
<form method="POST" enctype="multipart/form-data" class="grid max-w-2xl gap-6">
    @csrf
    <x-widget.file-upload id="cover" name="cover" label="Cover image" accept="image/*" max-size="25MB" />
    <x-widget.image-cropper for="cover" label="Crop the cover" aspect="16/9" :min-width="960" :max-width="1920" :max-height="1080" format="webp" class="max-w-2xl" />
</form>

Crop on the server

An image you already store, cropped on the server: src is its URL, and the crop is sent as crop[x], crop[y], crop[width], crop[height] and crop[rotate], in the image's own pixels after it's turned upright and rotated. The original stays as it was, so the crop can be changed later.

Pass $photoUrl from your controller.

Crop the photo

Drag or use the arrow keys to move the crop. Pinch, Ctrl and scroll, or + and − to zoom.

Show code
Blade
<form method="POST" class="grid gap-6">
    @csrf
    <x-widget.image-cropper :src="$photoUrl" name="crop" label="Crop the photo" aspect="4/3" />
</form>

Props

Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

<x-widget.image-cropper>

Prop Default Description
for null The id of the file input it crops for, e.g. a file upload's: a picked or dropped image opens here, and Apply puts the cropped one in the input in its place. The form, upload-url and wire:model then send that one. With multiple, each image picked is cropped in turn. SVG, GIF, other animated images and other files go through as they are, and it says so for images. One cropper an input. The cropped file is sRGB, and has no EXIF data: no location, and no date taken either, so read that before it's cropped if you need it. To crop the photo already saved again, pick it again, or crop it on the server with src. Or src.
src null An image already stored, to crop on the server: its URL. The crop is sent as name[x], name[y], name[width], name[height] and name[rotate], in the pixels of the image as shown, whose size is name[image_width] and name[image_height]: a resized copy works, scaled to the stored file on the server. A Content Security Policy's img-src must allow where it's served from, as for any image. Or for.
name null With src: the name the crop's fields submit under, e.g. "crop".
label 'Crop image' Above the image, and its name for screen readers.
aspect 1 Width over height of the crop: 1 for a square, "16/9", "4/3".
shape 'rect' The guide drawn over the image: rect, or circle for avatars. The image itself is always cut as a rectangle.
min-width null The narrowest crop, in the image's pixels, so zooming in can't leave too few to look sharp. A picture already narrower is still taken: refuse it on the server with the dimensions: rule if it must be larger.
max-width null With for: the widest image Apply makes; a larger crop is scaled down. Phones can't draw much past 16 megapixels.
max-height null With for: the tallest image Apply makes.
format null With for: jpeg, png or webp. Left out, it keeps the picked image's (jpeg, png, webp), and makes others png. Never one the input's accept leaves out: then the picked image's, jpeg or png. Where the browser can't write webp (Safari), a photo goes as jpeg, and one with transparency as png.
quality 0.9 With for: jpeg and webp quality, from 0 to 1.
help 'Drag or use the arrow keys to move the crop. Pinch, Ctrl and scroll, or + and − to zoom.' How to move and zoom, under the image.
size-message 'Crop :width by :height pixels' Read out as the crop changes. :width and :height are in pixels.
failed-message 'This image can\'t be edited here, so it will be sent as it is.' Shown when the browser can't open the picked image (HEIC outside Safari, a damaged file, or one over 100 megapixels or 100 MB, too large to open safely): it's sent uncropped, for the file upload's max-size and the server to judge.
load-failed-message 'The image couldn\'t be loaded.' With src: shown when the stored image doesn't load.
crop-failed-message 'This image couldn\'t be cropped. Try again, or pick a smaller one.' With for: shown when Apply can't make the cropped image (a phone out of memory). The crop stays, to try again.
uncropped-message 'Animated and vector images can\'t be cropped here, so they will be sent as they are.' With for: shown when an SVG or an animated image (GIF, WebP, PNG, AVIF) is picked: it's sent uncropped.
small-message 'This crop is under :min pixels wide, so it may look blurry.' Shown while the crop is narrower than min-width, as any crop of a smaller image is. :min is min-width.
progress-message 'Image :number of :count' With for, when several images are picked: which one is open. :number and :count.
rotate-left-label 'Rotate left' The rotate left button's name.
rotate-right-label 'Rotate right' The rotate right button's name.
zoom-out-label 'Zoom out' The zoom out button's name.
zoom-in-label 'Zoom in' The zoom in button's name.
reset-label 'Reset' The reset button's text: the whole image, unrotated.
apply-label 'Apply' With for: the button that crops the picked image and hands it to the input.
cancel-label 'Cancel' With for: the button that drops the picked image.
error null With src: the error message(s) to show. Defaults to Laravel's for the name and its fields (crop, crop.width…).
bag 'default' With src: the error bag to read errors from.

Accessibility

All 3 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.

Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.

Source

What bladewell:add image-cropper writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from the components it also adds and Field, and the theme and base CSS.

resources/views/components/widget/image-cropper/index.blade.php Show
index.blade.php
resources/js/widget/image-cropper/index.js Show
index.js
app/View/Widget/ElementIds.php Show
ElementIds.php
app/View/Widget/FormField.php Show
FormField.php