insitaction/easycrop
EasyCrop is a Laravel package that simplifies image cropping with a ready-to-use cropping workflow, configurable options, and straightforward integration for handling uploaded and saved images in PHP applications.
1. Install and confirm the constraint fits your stack.
composer require insitaction/easycrop:^1.0.5
composer why-not insitaction/easycrop
v1.0.5 (the current release) explicitly allows Symfony 7, so the old advice to expect a composer.json constraint bump is no longer the default. Run why-not anyway — if it reports a conflict now, it's your Stimulus version or a transitive dependency, not the Symfony constraint. Only fall back to patching if why-not points at a package you can't move.
2. Register the bundle. In config/bundles.php:
return [
// ...
Insitaction\EasyCropBundle\EasyCropBundle::class => ['all' => true],
];
Confirm the namespace in vendor/insitaction/easycrop/composer.json (autoload.psr-4) before copying this — the package name and the bundle namespace frequently don't match in smaller bundles.
3. Import the routes.
# config/routes/easycrop.yaml
easycrop:
resource: '@EasyCropBundle/Resources/config/routing.yaml'
If that path is wrong, php bin/console debug:router | grep -i crop will tell you what actually got registered.
4. Wire the Stimulus controller. v1.0.5 ships a bugfix for its Stimulus dependency, which confirms the widget is a UX Stimulus controller, not a standalone script. With Flex, check assets/controllers.json picked up the controller and that @hotwired/stimulus is installed. With AssetMapper, add the controller to importmap.php yourself. If the controller never connects, the widget renders as a dead <img> with no error — see the asset notes below.
5. Dump the config reference before writing any YAML. This is the single fastest way to learn the bundle's real surface area:
php bin/console config:dump-reference easy_crop
6. Where to look first, in order:
vendor/insitaction/easycrop/README.md — refreshed in v1.0.5, so read it before the older advice you'll find in issues. Still treat it as advertising.src/DependencyInjection/Configuration.php — the authoritative list of options, including any allowed-mimetype config touched by the v1.0.5 bugfix.src/Controller/ — the endpoint the JS widget posts to. This defines your payload shape.src/Resources/public/ and assets/ — the Stimulus controller and CSS you must register through your build pipeline.7. First use case. The canonical flow is: user uploads a full-size image → the crop widget overlays a box → the box is posted to the crop endpoint → a cropped file is written. Start by wiring that end-to-end on one entity (a User avatar or a Post thumbnail) before touching anything else. You want to see a file land on disk with the dimensions you asked for; everything else is refinement.
Non-destructive crops (preferred). Store the crop box on your entity, not the pixels:
#[ORM\Column(type: 'integer')] private int $cropX = 0;
#[ORM\Column(type: 'integer')] private int $cropY = 0;
#[ORM\Column(type: 'integer')] private int $cropWidth = 0;
#[ORM\Column(type: 'integer')] private int $cropHeight = 0;
Then render on demand — via the bundle's service, or by handing the box to LiipImagineBundle as a crop filter. You keep the original, users can re-crop, and you never lose pixels to a bad drag.
Destructive crops (escape hatch). Only when storage is genuinely constrained. Always write to image.original.jpg first, then overwrite, and keep the backup for a week. Once a user saves a bad crop over their only copy, you have a support ticket.
Form integration. Cropping is a two-phase operation and should look like one to the user: upload field on the form, crop widget on a follow-up step or in a modal after save. Keep the crop fields as hidden inputs populated by the Stimulus controller, and validate server-side that the box is inside the image bounds.
$builder
->add('imageFile', FileType::class, ['mapped' => false, 'required' => false])
->add('cropX', HiddenType::class)
->add('cropY', HiddenType::class)
->add('cropWidth', HiddenType::class)
->add('cropHeight', HiddenType::class);
Frontend assets. The bundle ships a Stimulus controller plus static CSS, not an Encore entry.
php bin/console assets:install public, confirm the controller appears in assets/controllers.json, then import your normal bootstrap.js. package.json must include @hotwired/stimulus and @symfony/stimulus-bundle.importmap.php and run php bin/console importmap:require @hotwired/stimulus. Skipping this is the "silently doesn't load" failure mode — no console error, just an inert widget.Version-pin Stimulus. Because v1.0.5 changes the bundle's Stimulus dependency handling, lock @hotwired/stimulus in package.json rather than floating. A Stimulus 3.2 vs 3.0 mismatch presents as "controller registered but never initialised", which is a miserable two-hour debug.
Coordinate space discipline. Decide once whether the box is in displayed pixels or natural image pixels, and enforce it everywhere. The cleanest approach: have the JS send naturalWidth/naturalHeight alongside the box, and scale server-side. Mixing spaces is the most common source of crops that are 30% off.
Multi-image entities and galleries. Loop the crop per attachment and key the payload (items[3][x], items[3][y], …). One request, one DB flush. Don't make the user save the entity five times.
Testing. A functional test beats a unit test here, because the real risk is the controller contract. After v1.0.5 specifically, add a mimetype case — the release fixes a mimetype bug, so assert both the accept path (PNG/JPEG) and the reject path (a .php renamed to .jpg, an SVG if unsupported):
$client->request('POST', '/easycrop', ['path' => 'uploads/test.jpg', 'x' => 10, 'y' => 10, 'width' => 100, 'height' => 100]);
$this->assertResponseIsSuccessful();
[$w, $h] = getimagesize($path);
$this->assertSame([100, 100], [$w, $h]);
$client->request('POST', '/easycrop', ['path' => 'uploads/evil.php.jpg', /* ... */]);
$this->assertResponseStatusCodeSame(400);
Path traversal is still the top risk. A crop endpoint that accepts a path and writes a file is a file-write primitive. v1.0.5 was a dependency and docs release — it did not touch the controller's security posture. If the controller takes the path from the request, wrap it in a voter or signed payload, resolve it with realpath() and assert it's inside your upload directory, and reject anything with .. or a leading /. Do not ship it as-is.
Re-test your upload validation on upgrade. The v1.0.5 mimetype fix means the accept/reject behaviour differs from v1.0.4. If you previously worked around a mime check in your own form type, that workaround is now layered on top of changed bundle behaviour — remove it and re-run your accept/reject tests for every format you support (JPEG, PNG, WebP, HEIC).
Still effectively a one-maintainer package. The Symfony 7 support is real and welcome, but this is 1 star, 0 dependents, and a repo that went ~2 years between releases. Plan accordingly: pin the exact version (1.0.5), keep your own functional tests around the endpoint, and budget a day for a fork if you need a patch. Treat the new documentation as improved but not guaranteed-current.
Symfony 7 is now allowed but not battle-tested. The constraint is open; the CI evidence isn't in the release notes. Smoke-test the full flow (upload → widget → crop → file on disk) on your actual Symfony version before committing the upgrade, and check composer why-not if anything pulls an older Stimulus Bundle.
Config defaults won't match your layout. Bundles of this vintage often default to %kernel.root_dir%/../web/uploads. On a modern Symfony app that path doesn't exist and the crop fails with a confusing "unable to open" rather than a config error. Set the upload/web dir explicitly on day one.
EXIF orientation. Phone photos carry an orientation flag. Crop the raw bytes without auto-orienting first and the box lands in the wrong place on roughly half of mobile uploads. Run Imagick::autoOrientImage() (or the GD equivalent) before cropping, or normalise on upload.
Memory and driver. A 12MP JPEG decoded with GD needs ~50MB. Batch-cropping a gallery blows the default memory_limit. Check php -m | grep -E 'gd|imagick', and bump to 256M+ if you accept large uploads.
Cache busting. Overwriting a file at the same path means browsers and CDNs serve the old crop for hours. Append ?v= . the crop timestamp to the URL, or write to a new filename each time.
Debugging checklist:
php bin/console debug:router | grep -i crop
php bin/console debug:container --tag=twig.extension | grep -i crop
php bin/console config:dump-reference easy_crop
php bin/console debug:stimulus # is the controller registered?
php bin/console assets:install public # is the JS actually on disk?
Then in the browser: does the element carry data-controller with the bundle's identifier, and is the controller present in the compiled assets/ bundle? Those two questions resolve most "the widget doesn't appear" reports. Log the incoming crop payload verbatim on the first integration — nine times out of ten the bug is visible in the numbers.
Extension points worth knowing. Override the route's controller in config/routes/easycrop.yaml (defaults: { _controller: App\Controller\CropController::crop }) to wrap or replace the shipped logic without forking; decorate the crop service in services.yaml for logging or validation; override templates via twig.paths if the widget needs restyling; and swap the Stimulus controller for your own by re-registering the identifier in assets/bootstrap.js if you need different JS behaviour. Prefer these overrides over patches — they survive a composer update that a hacked vendor/ file does not, and they're exactly what protects you if v1.0.5 turns out to be the last release for another two years.
How can I help you explore Laravel packages today?