thecyrilcril / laravel-imagekit
ImageKit uploads for Laravel, with optional first-party image compression and spatie/laravel-medialibrary interop.
Requires
- php: ^8.3
- illuminate/contracts: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- spatie/laravel-medialibrary: ^11.0
- thecyrilcril/imagekit-laravel-client: ^0.1
Requires (Dev)
- intervention/image: ^4.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.24
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
- phpstan/phpstan: ^2.1
Suggests
- intervention/image: Enables upload-time image compression (Laravel 13.20+)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-06 12:27:39 UTC
README
Upload media-library files to ImageKit. Compress images first if you want.
The package plugs into spatie/laravel-medialibrary. You keep writing addMedia(...)->toMediaCollection(...). Mark a collection with ->toImageKit() and its files go to ImageKit. $media->getUrl() returns the CDN URL once the file is there.
One setting, await, decides when the upload happens:
| App type | await |
Why |
|---|---|---|
| Web app | false (queued) |
The page re-renders later and picks up the CDN URL. |
| API | true (awaited) |
The response is the only chance to return the final URL. |
| Hybrid | false, then ->await() or uploadNow() where needed |
Most uploads queue; a few calls wait. |
Installation
composer require thecyrilcril/laravel-imagekit php artisan imagekit:install
Works on Laravel 12 and 13, Guzzle 7 or 8. No -W.
The command does the whole setup:
- Publishes this package's config, the Client's config (where the credentials live), and media-library's config and migration.
- Points media-library's
url_generatorat this package. - Asks for your three ImageKit credentials and a root folder, and writes them to
.env. - Offers to run the migrations.
It is safe to run twice. It skips anything already done and tells you. With --no-interaction it publishes the files, prints the env block for you to paste, and leaves .env and the database alone.
Manual installation
-
Publish both configs:
php artisan vendor:publish --tag=imagekit-config php artisan vendor:publish --tag=imagekit-client-config
config/imagekit-client.phpholds the credentials and HTTP settings.config/imagekit.phpholds this package's folder, queue, profiles and presets. -
Add to
.env:IMAGEKIT_PUBLIC_KEY= IMAGEKIT_PRIVATE_KEY= IMAGEKIT_URL_ENDPOINT= IMAGEKIT_FOLDER=my-app
See Root folder for
IMAGEKIT_FOLDER. -
Set the URL generator in
config/media-library.php:'url_generator' => \Thecyrilcril\ImageKit\ImageKitUrlBuilder::class,
The builder returns media-library's normal URL for anything not yet on ImageKit. Collections you have not opted in keep working.
-
Publish media-library's migration and migrate:
php artisan vendor:publish --tag=medialibrary-migrations php artisan migrate
This package's own migration adds
imagekit_pending_deletion_atto themediatable. It waits until themediatable exists. On a fresh app, runmigratea second time after the table is created.
Dependencies
This package talks to ImageKit through thecyrilcril/imagekit-laravel-client, a client built on Laravel's Http facade. It replaced the official imagekit/imagekit SDK in v0.6.0: the SDK pins Guzzle 7, Laravel 13 ships Guzzle 8, and upstream has not moved since 2024. The Client has no Guzzle pin, throws typed exceptions, and can be faked with ImageKitClient::fake(). Why this route and not a fork: ADR-0001.
Quick start
1. Prepare the model
The model must implement HasMedia and use InteractsWithMedia. That is media-library's rule. If the model already stores media, it is done.
Mark the collection with ->toImageKit():
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; class User extends Model implements HasMedia { use InteractsWithMedia; public function registerMediaCollections(): void { $this->addMediaCollection('avatar') ->singleFile() ->toImageKit('avatar'); // the 'avatar' upload profile, see Configuration } }
That is the whole integration. Uploading, compressing, serving CDN URLs and deleting all follow from normal media-library usage.
->toImageKit() with no argument uses the default profile. A collection without ->toImageKit() is untouched by this package. Adopt one collection at a time.
2. Upload and render
$user->addMedia($request->file('photo'))->toMediaCollection('avatar'); $user->getFirstMediaUrl('avatar'); // same as $media->getUrl(): the 'default' preset
With await: false (the default) the upload is queued. getUrl() returns the local URL until the job finishes, usually a few seconds. After that it returns the CDN URL. The same call always returns the URL that is correct right now.
The package's jobs run on the imagekit queue (imagekit.queue.name). Make sure a worker listens to it:
php artisan queue:work --queue=default,imagekit
Split queues
Laravel gives priority between queues, not inside one. A bulk operation that queues thousands of jobs sits in front of every new upload until it drains. To keep uploads first, move an action to its own queue with imagekit.queue.names. Each key is optional and reads its own env var; a null or empty value falls back to imagekit.queue.name:
'queue' => [ 'name' => env('IMAGEKIT_QUEUE', 'imagekit'), 'names' => [ 'upload' => env('IMAGEKIT_UPLOAD_QUEUE'), // PushFileToImageKit 'remove' => env('IMAGEKIT_REMOVE_QUEUE'), // RemoveFileFromImageKit 'cleanup' => env('IMAGEKIT_CLEANUP_QUEUE'), // Cleanup (Source removal) ], ],
IMAGEKIT_CLEANUP_QUEUE=imagekit-cleanup
Then list the default queue first, so a worker drains it before the split one. A queue no worker listens to strands its jobs:
php artisan queue:work --queue=default,imagekit,imagekit-cleanup
connection, tries and backoff stay shared across every action.
3. Serve a preset
A preset is picked by media-library's conversion name. Register a conversion with the preset's name, then ask for it:
use Spatie\MediaLibrary\MediaCollections\Models\Media; public function registerMediaConversions(?Media $media = null): void { $this->addMediaConversion('avatar')->performOnCollections('avatar'); }
$user->getFirstMediaUrl('avatar', 'avatar'); // same as $media->getUrl('avatar') // https://ik.imagekit.io/<id>/tr:w-200,h-200,fo-face,q-85,f-auto/my-app/avatar/photo.jpg
Media-library still generates its own local copy of the conversion. ImageKit does not use it.
API apps: await: true
An API response cannot wait for a queued job. Set await: true on the profile so the upload happens before the response:
// config/imagekit.php 'profiles' => [ 'avatar' => ['compress' => true, 'max_edge' => 2000, 'quality' => 90, 'format' => null, 'await' => true], ],
use Thecyrilcril\ImageKit\Concerns\RegistersImageKitCollections; public function store(Request $request): JsonResponse { $request->validate(['photo' => ['required', 'image', 'max:10240']]); $media = $request->user() ->addMedia($request->file('photo')) ->toMediaCollection('avatar'); return response()->json([ 'avatar_url' => $media->fresh()->getUrl(), 'ready' => RegistersImageKitCollections::isReady($media->fresh()), ]); }
Call $media->fresh(). The upload runs inside a media-library event listener. The $media object in your controller does not see what the listener wrote. fresh() re-reads it.
Base64 uploads work the same way. The trigger is media creation, not the HTTP shape:
$media = $request->user() ->addMediaFromBase64($request->string('photo')) ->usingFileName('avatar.jpg') ->toMediaCollection('avatar');
Read the URL with getUrl() on every response. Do not store it. If an awaited upload fails, the row keeps its local URL and a retry is queued. A stored URL goes stale when that retry succeeds.
Hybrid apps: uploadNow()
Keep await: false on the profile. Call ImageKit::uploadNow() where one endpoint must wait:
use Thecyrilcril\ImageKit\Facades\ImageKit; public function store(Request $request): JsonResponse { $media = $request->user() ->addMedia($request->file('photo')) ->toMediaCollection('avatar'); // profile says await: false, so this queues $result = ImageKit::uploadNow($media, 'avatar'); // this call waits return response()->json([ 'avatar_url' => $media->fresh()->getUrl(), 'uploaded_now' => $result !== null, ]); }
uploadNow() returns ?UploadedFileResult. It returns null when ImageKit is unreachable. It does not throw. The file is already safe on your local or S3 disk, the error is logged, a FileUploadFailed event fires, and a background retry is queued. Always check for null:
if ($result === null) { // ImageKit is down. The upload will retry in the background. // Decide whether the caller needs to know now. }
Hybrid apps: ->await()
The shorter form of the same thing. Add ->await() to the fluent chain and the upload happens before toMediaCollection() returns. The profile keeps await: false; nothing is queued for that row:
public function store(Request $request): JsonResponse { $media = $request->user() ->addMedia($request->file('photo')) ->await() // this one upload waits ->toMediaCollection('avatar'); return response()->json([ 'avatar_url' => $media->getUrl(), 'uploaded_now' => RegistersImageKitCollections::isReady($media->fresh()), ]); }
->await(false) does the reverse: it forces the queue on an await: true profile. Without a ->await() call the profile's value applies, so existing code is unchanged.
When ImageKit is down, ->await() fails the same way uploadNow() does above. It never throws for a transport failure. Read readiness with Thecyrilcril\ImageKit\Concerns\RegistersImageKitCollections::isReady($media->fresh()).
->await() works on every entry point in the table below, because it is a macro on media-library's FileAdder. Two things to know:
- Call
withCustomProperties()before->await(). The override travels on the row as a custom property that the package strips before it uploads;withCustomProperties()replaces the whole array and would drop it. Your owncustom_propertiesnever see the flag. ->await()on a collection without->toImageKit()throwsThecyrilcril\ImageKit\Exceptions\UnregisteredCollection, so a missing registration fails on the first run instead of silently queuing nothing. The exception is thrown after media-library has already stored the file and saved the row, so the row stays, serving its local URL, with nothing on ImageKit. Register the collection inregisterMediaCollections(), or delete the row in yourcatchif you would rather not keep it.
The uploadNow() pattern above still works and is not deprecated. Use it when the decision to wait comes after the file is stored.
Static analysis. Larastan reads registered macros, so ->await() type-checks with no annotation. Plain PHPStan does not know the macro; add a one-line hint where you call it:
/** @var \Spatie\MediaLibrary\MediaCollections\FileAdder $adder */ $adder = $user->addMedia($file);
await is explicit on purpose. Queued jobs and console commands have no HTTP request to inspect, so auto-detection would fail exactly where it matters.
Cleanup
Once a file is on ImageKit, its Source (the original, its conversions and its responsive images on the media disk) is dead weight: ImageKit serves the file and every conversion. Cleanup deletes the Source once the row carries imagekit.file_id, and never before. It is off by default.
Warning. After Cleanup the file exists only on ImageKit. Deleting it there, or losing the ImageKit account, loses the file. Turn this on only when ImageKit is meant to be the single source of truth.
Turn it on for every upload through a profile, or for one call on the chain:
// config/imagekit.php 'profiles' => [ 'avatar' => ['compress' => true, 'max_edge' => 2000, 'quality' => 90, 'format' => null, 'await' => false, 'cleanup' => true], ],
$user->addMedia($request->file('photo')) ->cleanup() // this one upload drops its Source ->toMediaCollection('avatar'); $user->addMedia($request->file('photo')) ->cleanup(false) // keep the Source on a cleanup:true profile ->toMediaCollection('avatar');
->cleanup() is a macro next to ->await(). ->await()->cleanup() and ->cleanup()->await() behave the same. It works on the awaited path, the queued path and a manual uploadNow() alike, and it carries the same two caveats as ->await(): call withCustomProperties() before it, and on a collection without ->toImageKit() it throws UnregisteredCollection. The override never stays in custom_properties. Plain PHPStan needs the same @var FileAdder hint shown above.
How it runs:
- Cleanup is a queued job,
Thecyrilcril\ImageKit\Jobs\CleanupSource, dispatched after the row is saved withimagekit.file_idand after the transaction commits. It lands onimagekit.queue.names.cleanup, or the default queue when that is unset (see Split queues). - The job re-checks the row before it deletes anything. A row that is gone, or that has no
file_id, is left alone. Files that are already gone are not an error, so a retry is harmless. - It removes the original, every conversion and every responsive image, and the empty directories, through media-library's own file remover, so a custom
file_remover_classand a separate conversions disk are honoured. If the original, a registered conversion or a recorded responsive image is still on disk afterwards the job logs one warning and throwsThecyrilcril\ImageKit\Exceptions\CleanupFailed, so the queue retries it with the package'striesandbackoff. getUrl()keeps returning the ImageKit URL. Deleting the row later still queues the remote delete.
Two things it cannot fix:
- Do not combine
withResponsiveImages()with Cleanup. Responsive images are served from the media disk, not from ImageKit, so everysrcsetentry breaks once the Source is gone. The job logs one warning, after the delete succeeds, when it cleans a row that carries responsive-image data; a retry afterCleanupFaileddoes not repeat it. getPath()points at nothing after Cleanup. Code that reads the Source from disk (an EXIF reader, a virus scanner) must run before Cleanup, or on a profile withcleanupoff.
Conversions and responsive images are generated on media-library's own queue after the upload, so Cleanup can run first. A conversion that finds no Source is skipped silently and ImageKit serves it anyway. A responsive-image job that finds no Source fails into failed_jobs. There is no delay knob. Why a queued job and not an inline delete: ADR-0003.
Files uploaded before the flag existed can be cleaned in bulk. This queues one job per row that carries imagekit.file_id, skips rows marked for deletion, and returns the number queued:
use Thecyrilcril\ImageKit\Facades\ImageKit; $queued = ImageKit::cleanup(User::class, 'avatar');
Every upload method works
The push to ImageKit is triggered by media creation. All media-library entry points behave the same, and all of them accept ->await() and ->cleanup():
| Method | Source |
|---|---|
addMedia() |
An UploadedFile (multipart form) |
addMediaFromRequest() |
A named field on the current request |
addMediaFromBase64() |
A base64 data URI |
addMediaFromStream() |
A PHP stream |
addMediaFromUrl() |
A remote URL |
addMediaFromDisk() |
A file already on one of your disks |
Configuration
Root folder
IMAGEKIT_FOLDER (imagekit.folder, default uploads) is the folder every upload lands under. The final path is {folder}/{collection}/{file}. With IMAGEKIT_FOLDER=kitwire, a file in avatars is stored at kitwire/avatars/photo.jpg. Leading and trailing slashes are ignored.
Give every application and every environment that shares an ImageKit account its own root, for example kitwire and kitwire-staging. The root keeps files apart, and imagekit:reconcile never looks outside it. ImageKit creates the folder on first upload.
Before v0.3.0 uploads went to
/{collection}/.... Files already on ImageKit stay where they are and keep working. Only new uploads use the root folder.
Profiles and presets
config/imagekit.php has two sections:
profilescontrol what is stored: compression before upload.presetscontrol what is served: ImageKit transformations on the URL.
'profiles' => [ 'avatar' => ['compress' => true, 'max_edge' => 2000, 'quality' => 90, 'format' => null, 'await' => false], ], 'presets' => [ 'avatar' => ['width' => 200, 'height' => 200, 'focus' => 'face', 'quality' => 85, 'format' => 'auto'], ],
A profile name and a preset name are independent. The shipped config uses matching names for readability only.
Profile keys:
| Key | Meaning |
|---|---|
compress |
false uploads the original bytes untouched. |
max_edge |
Longest side in pixels. Images are scaled down to this, never up. |
quality |
Integer 1–100 for the encoder. Ignored for lossless formats such as PNG. |
format |
Output format ('jpg', 'png', 'webp', …), or null to keep the source format. Read Footguns first. |
await |
false queues the upload. true uploads before the storing call returns. ->await() / ->await(false) on the chain overrides it for one call. |
cleanup |
false keeps the Source on the media disk. true deletes it once ImageKit holds the file, so the file exists only on ImageKit. ->cleanup() / ->cleanup(false) on the chain overrides it for one call. Read Cleanup first. |
A profile is validated the first time it is used. A bad value throws Thecyrilcril\ImageKit\Exceptions\InvalidProfile with the profile and field name. Bad values are: quality outside 1–100, max_edge below 1, a non-string format, or a numeric string where an integer is expected (for example '90' from env()). Nothing is clamped or coerced. An unused profile never throws.
Preset keys are the Client's aliases for ImageKit's transformation parameters (width, height, focus, quality, format, crop, blur, radius, …), or the short codes themselves (w, fo, q). A key the Client does not know throws Thecyrilcril\ImageKitClient\Exceptions\InvalidTransformation when the URL is built, so a typo fails loudly instead of serving a broken image. Transformations go in the URL path (/tr:w-200,h-200/…); see ADR-0002.
Compression
Compression runs only when all three are true:
- Laravel 13.20 or newer (ships
Illuminate\Support\Facades\Image). intervention/imageis installed.- The GD or Imagick PHP extension is loaded.
If one is missing, the package uploads the original bytes and logs one warning per process:
ImageKit: image compression is unavailable, uploading originals. Requires Laravel 13.20+ with intervention/image and a GD or Imagick driver.
Compression applies only where it makes sense:
| File type | Compressed? |
|---|---|
| Raster images (JPEG, PNG, WebP, GIF, …) | Yes. Resized to max_edge, re-encoded at quality. |
| SVG, video | No. Uploaded as-is. ImageKit still applies presets on read. |
| Plain files (PDF, DOCX, XLSX, ZIP, …) | No. Uploaded as-is. Served as a plain URL, with no transformation. |
Compression removes EXIF. GD strips EXIF on every re-encode, even JPEG to JPEG, and Illuminate\Image uses GD by default. If you need capture time, GPS or copyright fields, use Conversion instead.
Conversion (HEIC, WebP, AVIF → JPEG)
Conversion is optional and separate from compression. Use it when you need a JPEG that keeps its EXIF. The common case is an iPhone upload: Safari hands over HEIC, and many image pipelines and vision APIs reject it.
use Thecyrilcril\ImageKit\Contracts\ConvertsImages; public function store(Request $request, ConvertsImages $converter) { $bytes = $request->file('photo')->get(); $jpeg = $converter->toJpeg($bytes, $request->file('photo')->getClientOriginalName()); }
| Source | Result |
|---|---|
| HEIC / HEIF, WebP, AVIF | Converted to JPEG. EXIF preserved. |
| JPEG | Returned byte for byte. Never re-encoded. |
| Anything else, or an environment that cannot convert | Returned unchanged. |
Format is detected by magic bytes, never by filename. Phones often name a HEIC file photo.jpg.
Convert before you upload, and read the local file. The CDN strips EXIF on delivery (see Footguns). Converting through a CDN transformation loses the metadata you converted to keep.
Check support first
if (! $converter->supported('heic')) { // Refuse the upload, or accept it unconverted. }
supported() does a trial decode of a real sample file. It does not trust Imagick::queryFormats(). That list reports registered coders, not working ones. Read and write support can differ.
Requirements
Conversion needs the imagick extension. Without it the package binds a null converter that returns the original bytes and logs one notice.
HEIC also needs libheif with an HEVC decode plugin. On Ubuntu and Debian the plugin is only a Suggests, so install it:
sudo apt install libheif-plugin-libde265
On a managed platform, check supported('heic') before you rely on it. Without support, uploads proceed unconverted. A HEIC that reaches a JPEG-only service is still rejected there.
Errors
toJpeg() throws ConversionFailed only for a supported format whose bytes are corrupt or truncated. An unsupported environment is not an error. Use supported() to check, not try/catch.
Out of scope
JPEG XL, DNG/RAW, Ultra HDR and Motion/Live Photos are not handled. The last two are already valid JPEGs and need nothing special.
Deletion
Deleting media through media-library also deletes the file from ImageKit. This covers every path:
$media->delete()$model->clearMediaCollection(...)singleFile()collections, when a new upload replaces the old one- Cascading deletes when the owning model is deleted
The remote delete runs after the database transaction commits. A rolled-back transaction never deletes a remote file.
Only files this package uploaded are tracked. A file uploaded through ImageKit's dashboard or API has no imagekit.file_id on any Media row, so the package cannot clean it up.
Finding orphans
An orphan is a remote file with no local record. Sources: files from before you adopted this package, rows removed by raw SQL or a restored backup, uploads made outside media-library. Find them with:
php artisan imagekit:reconcile
This lists and does not delete. To delete:
php artisan imagekit:reconcile --delete
Every run stays inside IMAGEKIT_FOLDER. Files outside it are never listed and never deleted. If IMAGEKIT_FOLDER is empty, the command lists the whole account, but --delete refuses to run.
Options: --folder=avatars scans one sub-folder under the root. --chunk=100 sets files fetched per request.
Read the listing before you pass
--delete.An orphan is any remote file this application's media table does not know. Point a staging app at the production folder and every production file looks orphaned. The root folder protects other applications, not a second environment of this one. Give each environment its own root (see Root folder).
The command refuses
--deletewhen no media row references ImageKit at all. An empty local side looks the same as the wrong account.
Testing
Call ImageKit::fake(), then assert on it:
use Thecyrilcril\ImageKit\Facades\ImageKit; it('uploads the avatar', function () { $fake = ImageKit::fake(); $user->addMedia(UploadedFile::fake()->image('a.jpg'))->toMediaCollection('avatar'); $fake->assertUploaded($media); });
| Assertion | Checks |
|---|---|
assertUploaded(Media $media, ?string $profile = null) |
This row was uploaded (via upload() or uploadNow()). With profile: 'photos', only an upload that used that profile counts. A collection registered with a plain ->toImageKit() records 'default'. |
assertNotUploaded(Media $media) |
This row was not uploaded. |
assertDeleted(string $fileId) |
This ImageKit file ID was deleted. |
assertNothingUploaded() |
No uploads happened in the test. |
assertNothingDeleted() |
No deletions happened in the test. Fails with the list of ids that were deleted. |
A faked awaited upload (an await: true profile, ->await(), or uploadNow()) does what the real manager does on success: it writes imagekit.file_id (fake-{id}) and imagekit.file_path (/ + the resolved folder + / + the file name) on the row, saves it, and fires FileUploaded. So the row is ready, getUrl() returns the ImageKit URL, and your own listeners run:
ImageKit::fake(); $media = $user->addMedia(UploadedFile::fake()->image('a.jpg'))->await()->toMediaCollection('avatar'); expect($media->fresh()->getCustomProperty('imagekit.file_path'))->toBe('/uploads/avatar/a.jpg');
A faked awaited upload also queues the Cleanup job when the profile has cleanup: true or the call used ->cleanup(), so Queue::fake() plus Queue::assertPushed(CleanupSource::class) proves the wiring. ImageKit::fake()->cleanup() returns 0, like backfill().
A queued upload (upload(), or an await: false profile) writes nothing, so "not ready until a worker runs" stays true in tests. Row deletions are recorded too: the remove job goes through the bound client, so deleting a row that carries imagekit.file_id shows up in assertDeleted() and assertNothingDeleted().
To simulate an outage, make uploadNow() return null. The row is left untouched and nothing fires:
$fake = ImageKit::fake()->failUploads(); $result = ImageKit::uploadNow($media, 'avatar'); expect($result)->toBeNull();
If you implement Thecyrilcril\ImageKit\Contracts\ImageKitClient yourself, note that upload() and uploadNow() take a third ?bool $cleanup = null parameter and the contract has a bulk cleanup(string $modelClass, string $collection): int.
For injection, type-hint the Thecyrilcril\ImageKit\Contracts\ImageKitClient contract. It is bound as a singleton, and ImageKit::fake() swaps that binding, so injected consumers get the fake too. ImageKitManager is final and is not the bound singleton, so an injected ImageKitManager is not swapped by the fake.
Hosting note
An API-only app may have no writable public/ directory and no storage:link. Then there is nowhere to serve a local URL from while an upload is queued. Use await: true on those profiles so every response carries a real CDN URL.
Footguns
format: 'png' on a photo makes the file larger. PNG is lossless and ignores quality. Keep format: null, or use 'webp' for photos.
format: 'jpeg' flattens transparency. JPEG has no alpha channel. Transparent pixels in a PNG or WebP source are filled with a solid background. The package logs a warning and continues. Decide on purpose if your sources can be transparent.
The CDN strips EXIF on delivery. Measured across five delivery variants: no-transform, ?tr=f-jpg, ?tr=w-800 and ?tr=f-jpg,q-80 all removed the metadata. Only ?tr=orig-true kept it. A consumer that needs EXIF must convert before upload and read the local file, never the CDN URL. See Conversion.