shortpixel/shortpixel-php
Official PHP SDK for ShortPixel image optimization. Compress JPEG, PNG, GIF, WebP, AVIF and PDF files via the ShortPixel API using lossy, glossy or lossless modes, with bulk processing, CDN delivery and framework-agnostic support for any PHP app, including Laravel.
The package is framework-agnostic — there is no service provider, facade, or publishable config. Installation is a single Composer require, and everything is driven through static options plus namespaced helper functions.
composer require shortpixel/shortpixel-php
Put the credentials in config/services.php so config:cache works correctly:
// config/services.php
'shortpixel' => [
'key' => env('SHORTPIXEL_API_KEY'),
'lossy' => (int) env('SHORTPIXEL_LOSSY', 1), // 0 lossless, 1 lossy, 2 glossy
'convertto'=> env('SHORTPIXEL_CONVERT', '+webp'), // '' to disable
],
Where to look first, in this order:
README.md — the canonical ShortPixel::setOptions() + fromFiles() / fromUrls() / fromFolder() examples.src/ShortPixel.php — the static options registry and the entry points you will actually call.src/Result.php — the object you code against. Read every public method before writing your wrapper; this is the real API surface (isSuccess(), getSavedFile(), getOriginalFile(), getPercentImprovement(), getMessage()).src/Helper/UtilHelper.php — path normalization and option parsing, useful when things behave unexpectedly.src/ShortPixelClient.php — present in newer releases; a lower-level client if you need explicit request control or async handling instead of the static helpers.A minimal first use case is a queued job that optimizes one uploaded image. This is the shape you will reuse everywhere:
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use ShortPixel\ShortPixel;
class OptimizeImage implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 180;
public int $tries = 3;
public function __construct(public string $absolutePath) {}
public function handle(): void
{
ShortPixel::setOptions([
'key' => config('services.shortpixel.key'),
'lossy' => config('services.shortpixel.lossy'),
'convertto' => config('services.shortpixel.convertto'),
'wait' => 30,
]);
foreach (ShortPixel\fromFiles($this->absolutePath) as $result) {
if (! $result->isSuccess()) {
$this->fail(new \RuntimeException($result->getMessage()));
return;
}
// $result->getSavedFile(), $result->getPercentImprovement()
}
}
}
Wrap it in one injectable service. Because options are static/global, configure them in exactly one place and bind it as a singleton. Scattering ShortPixel::setOptions() calls across controllers is the fastest way to get inconsistent compression settings.
namespace App\Services;
use ShortPixel\ShortPixel;
class ImageOptimizer
{
public function __construct()
{
ShortPixel::setOptions([
'key' => config('services.shortpixel.key'),
'lossy' => config('services.shortpixel.lossy'),
'convertto' => config('services.shortpixel.convertto'),
'wait' => config('services.shortpixel.wait', 30),
]);
}
/** @return \ShortPixel\Result[] */
public function optimize(string|array $paths): array
{
return iterator_to_array(ShortPixel\fromFiles($paths));
}
}
One job per image, never inline in a request. The SDK blocks while it uploads and polls. A controller action that loops over a gallery will time out under PHP-FPM. Dispatch a job per file, keep $timeout on the job above the SDK's wait, and set Horizon/retry_after accordingly.
Backfills via fromFolder() in an Artisan command. For a one-off migration of storage/app/public/uploads, a command with $this->output->progressStart() around ShortPixel\fromFolder($path) is far more practical than dispatching thousands of jobs. Chunk the folder walk your self if it is huge; the SDK will happily try to process everything in one call.
Remote disks require a local round-trip. fromFiles() needs readable local paths. For S3/R2, Storage::disk('s3')->readStream() into storage/app/tmp/, optimize, then push the optimized file back. Use fromUrls() only for genuinely public URLs — it cannot see private objects.
Persist the outcome, not just the file. Add columns like optimized_path, original_bytes, optimized_bytes, optimized_at to your media table. You need the byte counts to prove savings, and optimized_at to make the job idempotent (skip files with a matching hash — re-optimizing burns credits for nothing).
Treat WebP/AVIF as an additional artifact, not a replacement. With 'convertto' => '+webp' the SDK produces a sibling file. Store both paths, serve via <picture> with the original as fallback, and be explicit about which Result accessor returns the converted path in your installed version.
Classify failures instead of blanket-retrying. isSuccess() === false covers everything from "not an image" to "API key invalid" to "quota exceeded". Hard failures should call $this->fail(); transient ones (network, processing) should $this->release(60). Log getMessage() and the status code so you can tell them apart.
Test against an interface, not the SDK. Define ImageOptimizerContract with one optimize() method, bind the real implementation in AppServiceProvider, and swap in a fake in tests. There is no sandbox key, so any integration test costs real credits — keep those manual and use a 10 KB fixture.
ShortPixel::setOptions() persists between jobs. If you ever use multiple keys (per-tenant, per-plan), always set the full option array at the start of each job rather than assuming the previous state.Result object rather than throwing. Code that only wraps the call in try/catch will silently produce unoptimized images. Always branch on isSuccess().wait is a polling budget, not a per-request timeout. Set it too low and in-flight images come back as "processing"; set it too high and your job sits idle. For large batches, prefer the async path (ShortPixelClient or the async API mode) plus your own scheduled poller, and keep wait small.image / mimes rules before burning a queue slot and a credit on a rejection. CMYK JPEGs need 'cmyk2rgb' => 1.getSavedFile() is only meaningful on success. Calling it on a failed result gives you nulls or notices. Guard every accessor with isSuccess().backup_path outside the web root or copy the original to storage/app/private/originals/ first. Your S3 versioning does not protect local disk.^x.y constraint, read src/Result.php after every upgrade, and keep the wrapper thin so SDK changes are contained to one class.How can I help you explore Laravel packages today?