nwtn/php-respimg
nwtn/php-respimg is a PHP library for responsive image handling: generate resized image variants, build srcset and sizes markup, and serve appropriately sized images across devices with simple configuration for PHP applications.
Check your environment first. This package is an orchestration layer over native binaries; it does not resize or compress in pure PHP. Before composer require, confirm on the box that will run it:
convert -version # ImageMagick
identify -list format | grep -i webp
jpegoptim --version
pngquant --version
node --version && npm --version # respimg is a Node CLI wrapper
Missing binaries are the #1 reason a first run "succeeds" but silently skips WebP/AVIF output.
Install and publish config:
composer require nwtn/php-respimg
php artisan vendor:publish --tag=respimg
Read these three files, in this order:
config/respimg.php (or whatever the publish tag drops) — this is where breakpoints, quality, output formats, and cache path live. Everything you tune day-to-day is here.Respimg::process($path) returning markup). That is the only public API surface you should rely on.First use case — the "upload one image, get a srcset" loop:
use Nwtn\Respimg\Facades\Respimg;
$markup = Respimg::process(
storage_path('app/public/uploads/hero.jpg')
);
// $markup is an <img srcset="..." sizes="..." src="...">
Drop it into a Blade view with {!! $markup !!} (it is HTML, not escaped). If your first run returns markup but the derivative files aren't on disk where you expect, jump straight to Gotchas.
Do not stop here. Before you ship a single <img> using Respimg::process() directly from a controller or Blade, read the Implementation Patterns section — calling the package inline is a trap you cannot easily unwind.
Wrap it. Always wrap it. The package's public API is the wrong dependency boundary for your app. Define a contract once and implement it with this package:
namespace App\Contracts;
interface ImageTransformer
{
/**
* @return array{src: string, srcset: string, sizes: string, width: int, height: int}
*/
public function variants(string $sourcePath, array $widths, array $formats = ['jpg','webp']): array;
}
namespace App\Services\Images;
use App\Contracts\ImageTransformer;
use Nwtn\Respimg\Facades\Respimg;
class RespimgTransformer implements ImageTransformer
{
public function variants(string $sourcePath, array $widths, array $formats = ['jpg','webp']): array
{
$result = Respimg::process($sourcePath, [
'widths' => $widths,
'formats' => $formats,
]);
return [
'src' => $result->src,
'srcset' => $result->srcset,
'sizes' => $result->sizes,
'width' => $result->width,
'height' => $result->height,
];
}
}
Bind it in AppServiceProvider, and call ImageTransformer everywhere else. This is the entire risk-mitigation strategy for an unmaintained package — if you later swap to spatie/laravel-medialibrary or imgproxy, it is a one-file change.
Never call it in the request path. Image transforms are CPU-bound, spawn a subprocess, and write to disk. Every one of those is a latency landmine. Do this instead:
final class GenerateImageVariants implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 300;
public int $tries = 3;
public function __construct(
public MediaAsset $asset,
public array $widths = [320, 640, 960, 1280, 1600, 1920],
) {}
public function handle(ImageTransformer $transformer): void
{
$result = $transformer->variants(
$this->asset->sourcePath(),
$this->widths,
['jpg', 'webp'],
);
$this->asset->update(['variants' => $result, 'variants_generated_at' => now()]);
}
public function failed(\Throwable $e): void
{
Log::error('Image transform failed', ['asset' => $this->asset->id, 'error' => $e->getMessage()]);
}
}
Dispatch from an observer on your upload model, and put the job on a dedicated worker queue/image connection so heavy transforms don't starve normal jobs.
Render through a Blade component, not raw markup. This gives you a single place to add loading="lazy", decoding="async", aspect-ratio, and <picture>/WebP <source> fallback logic — and a single place to add placeholder markup for images whose variants haven't generated yet:
{{-- resources/views/components/responsive-image.blade.php --}}
@props(['asset', 'alt' => '', 'sizes' => '100vw'])
@if ($asset->variants_generated_at)
<img
src="{{ $asset->variants['src'] }}"
srcset="{{ $asset->variants['srcset'] }}"
sizes="{{ $sizes }}"
width="{{ $asset->variants['width'] }}"
height="{{ $asset->variants['height'] }}"
alt="{{ $alt }}"
loading="lazy"
decoding="async">
@else
<img src="{{ $asset->placeholderUrl() }}" alt="{{ $alt }}" width="800" height="600">
@endif
Pre-generate at upload time, and backfill in batches. Two workflows:
GenerateImageVariants → job writes derivatives + rows. User sees a placeholder until done.MediaAsset::whereNull('variants_generated_at')->chunkById(100, ...) and dispatches jobs. Run it once on adoption, feature-flagged behind a config toggle.Store derivative names yourself, not the package's defaults. Use a deterministic, content-addressed path like images/{sha256(source)}/{width}.{format}. That gives you free cache invalidation (new source → new hash → new paths), safe concurrent generation, and a swap path to a different backend later.
Test the wrapper, not the package. Write a fake implementation of ImageTransformer for feature tests so your test suite never touches Node or ImageMagick. Reserve a single integration test for the real implementation, gated behind an env flag so CI can skip it on machines without the binaries:
#[Group('integration')]
class RespimgTransformerTest extends TestCase
{
protected function setUp(): void
{
if (! config('services.images.real_backend_enabled')) {
$this->markTestSkipped('Image binaries not available');
}
}
}
Silent no-ops are the failure mode you will most likely hit. If jpegoptim, pngquant, or cwebp is missing, expect the workflow to still exit 0 and produce JPEG-only derivatives. Log the resulting derivative list, not just "job succeeded" — alert on missing formats, not on job status:
if (! in_array('webp', collect($result['srcset'])->pluck('format')->all())) {
Log::warning('WebP requested but not produced — check cwebp in worker image');
}
Filenames cross a shell boundary. Anything that lands in a path handed to a subprocess is suspect. Sanitise upload filename components (basename, strip non-[a-zA-Z0-9._-], cap length) before they reach the package. If a value is user-controlled (original filename, an S3 key constructed from request input), assume command-injection review is required, not optional.
Write-then-rename, or accept corrupt derivatives. The wrapper likely writes each output file directly. If a job is killed mid-write you get zero-byte or truncated derivatives that the web server happily serves with a 200. Either patch the wrapper to write to *.tmp and rename() on success (small enough change), or add a post-generation validation step that reads image dimensions back with getimagesize() and deletes anything that doesn't parse.
No manifest = duplication and orphans. The package has no idempotency layer. Your own media_asset_variants table (source hash, width, format, storage path, bytes) is not optional — it is what makes re-runs safe, lets you skip-if-exists, and lets you reap derivatives when the source is replaced. Without it you will accumulate orphan files forever.
PHP 8 / Laravel 10+ compatibility is unverified. This is likely Laravel 5.x-era code. Before adopting, spike a fresh Laravel app on your target PHP version and confirm:
composer require resolves without conflictIf it fails on PHP 8, a vendored fork is typically faster than a shim (the wrapper is small), but set a hard timebox — if it takes more than a couple of days, switch to intervention/image v3 or a managed service.
Never trust it with request-path resizing. Not because it can't, but because it shouldn't. A /images/foo-800.jpg route that dynamically calls this package will convert image bytes directly into p99 latency and PHP-FPM starvation. Pre-generate.
Watch memory on large PNGs and multi-page inputs. ImageMagick will happily eat gigabytes on a 4000×4000 PNG or a TIFF with 20 pages. Set a -limit memory and -limit map on the IM invocation if the wrapper allows passing extra args, and cap source dimensions at upload validation with getimagesize(). On the queue side, set $timeout and $tries conservatively and route failed jobs to a dead-letter queue so retries don't hammer a bad source.
Different image sizes in dev vs. prod are almost always mismatched binaries or config. If width=960 derivatives come out different heights on your laptop vs. the staging worker, look at: ImageMagick version, the config file actually loaded (publish vs. package default), and whether environment variables like RESPIMG_QUALITY are set differently. Add the resolved config to your first-log line for every transform job.
Extension points worth using:
config/respimg.php but source them from the same design tokens as your CSS. A 960px breakpoint in <img sizes> that doesn't match your @media (min-width: 960px) is a wasted download.sizes attribute: this is your lever, not the package's. The package emits some default — override it per call site. 100vw on a card grid is wrong and you will ship double-sized images everywhere.webp (and avif if avifenc exists in your worker image) but always emit a JPEG fallback in the src. The package may or may not do <picture> automatically; if not, build that in your Blade component.storage/app/respimg — the package will not warn you, it will just fail to write and return paths to files that don't exist.Debugging checklist, in order, when "it doesn't work":
# 1. Are the binaries on PATH *inside the container/worker*?
docker run --rm your-worker-image bash -c 'which convert jpegoptim pngquant cwebp node'
# 2. Did the transform actually run without an exit-0 silent skip?
# Look for the derivative file on disk.
ls -la storage/app/respimg/
# 3. Is the resolved config what you expect?
php artisan tinker --execute="dump(config('respimg'));"
# 4. Is the subprocess failing but the wrapper swallowing the error?
# Re-run the underlying command by hand on the same file.
# 5. Is the path crossing the disk boundary correctly?
# If source is on S3, this package likely only handles local FS — download first.
Treat this as vendored code from day one. Add a README note in your repo tagging the version, an owner, and the native-binary prerequisites. That is the single cheapest thing you can do to survive the day when the worker image rebases and pngquant disappears.
How can I help you explore Laravel packages today?