<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
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.
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().
<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.
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.
Examples
These examples also use File upload, which Image cropper doesn't need on its own. To use them as they are:
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.
Show code Hide code
<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.
Show code Hide code
<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.
Show code Hide code
<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
resources/js/widget/image-cropper/index.js Show
app/View/Widget/ElementIds.php Show
app/View/Widget/FormField.php Show