Search by

thecyrilcril / laravel-imagekit

thecyrilcril

ImageKit uploads for Laravel, with optional first-party image compression and spatie/laravel-medialibrary interop.

Package info

github.com/thecyrilcril/laravel-imagekit

pkg:composer/thecyrilcril/laravel-imagekit

Statistics

Installs: 238

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 1

v0.8.0 2026-09-06 12:27 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:

  1. Publishes this package's config, the Client's config (where the credentials live), and media-library's config and migration.
  2. Points media-library's url_generator at this package.
  3. Asks for your three ImageKit credentials and a root folder, and writes them to .env.
  4. 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
  1. Publish both configs:

    php artisan vendor:publish --tag=imagekit-config
    php artisan vendor:publish --tag=imagekit-client-config

    config/imagekit-client.php holds the credentials and HTTP settings. config/imagekit.php holds this package's folder, queue, profiles and presets.

  2. Add to .env:

    IMAGEKIT_PUBLIC_KEY=
    IMAGEKIT_PRIVATE_KEY=
    IMAGEKIT_URL_ENDPOINT=
    IMAGEKIT_FOLDER=my-app

    See Root folder for IMAGEKIT_FOLDER.

  3. 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.

  4. 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_at to the media table. It waits until the media table exists. On a fresh app, run migrate a 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 own custom_properties never see the flag.
  • ->await() on a collection without ->toImageKit() throws Thecyrilcril\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 in registerMediaCollections(), or delete the row in your catch if 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 with imagekit.file_id and after the transaction commits. It lands on imagekit.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_class and 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 throws Thecyrilcril\ImageKit\Exceptions\CleanupFailed, so the queue retries it with the package's tries and backoff.
  • 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 every srcset entry 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 after CleanupFailed does 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 with cleanup off.

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:

  • profiles control what is stored: compression before upload.
  • presets control 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:

  1. Laravel 13.20 or newer (ships Illuminate\Support\Facades\Image).
  2. intervention/image is installed.
  3. 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 --delete when 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.