Skip to content

Image Processing Worker with ImageMagick in Winter Boot

User uploads need variants: a small thumbnail, a display-size image, a modern format — and none of that work belongs in the request. This guide builds an upload endpoint that stores the original and enqueues a job message, plus an SQS-backed worker that generates the variants with ImageMagick via the PHP imagick extension. Consumers start automatically with the app — no separate worker process to launch.

This guide processes images with ImageMagick via the PHP imagick extension. (PHP’s GD extension can also do image work and is fine to substitute — the pipeline below stays the same.)

Prerequisites: the extension and the underlying binaries (check with php -m | grep imagick), the Winter SQS module, and a queue to talk to:

Terminal window
sudo apt install imagemagick libmagickwand-dev
pecl install imagick
# then enable extension=imagick.so in php.ini
composer require suvera/winter-boot suvera/winter-modules

For local development, run ElasticMQ, an SQS-compatible emulator — no AWS account needed:

Terminal window
docker run -d -p 30932:9324 softwaremill/elasticmq-native
aws sqs create-queue --queue-name media-jobs \
--endpoint-url http://localhost:30932 --region elasticmq
POST /media ──► store original, insert job (pending),
send {jobId} to SQS, return 202 + job id
│
SQS consumer (auto-started) ──┘──► validate → variants → mark done (or failed)
GET /media/{id} ──► job status + variant paths

Requests stay fast (store + insert + send); all pixel work happens in consumer workers where CPU time and memory don’t block request handling. The queue also absorbs spikes — a hundred simultaneous uploads become a hundred messages, processed at workerNum concurrency instead of thundering the image library at once.

Four files: the tracking table, the controller that accepts uploads and enqueues jobs, the consumer that the SQS workers run, and the service that processes pixels. Switch between them:

The boundary — start here. Accepts one file field, rejects anything that is not a clean upload, stores it under a random name (nothing of the client filename survives), records the job, and sends its id to the queue.

<?php
declare(strict_types=1);
namespace dev\example\rest;
use dev\winterframework\pdbc\PdbcTemplate;
use dev\winterframework\pdbc\ex\EmptyResultDataAccessException;
use dev\winterframework\sqs\SqsService;
use dev\winterframework\stereotype\Autowired;
use dev\winterframework\stereotype\RestController;
use dev\winterframework\stereotype\Value;
use dev\winterframework\stereotype\web\GetMapping;
use dev\winterframework\stereotype\web\PathVariable;
use dev\winterframework\stereotype\web\PostMapping;
use dev\winterframework\stereotype\web\RequestMapping;
use dev\winterframework\web\http\HttpRequest;
use dev\winterframework\web\http\HttpUploadedFile;
use dev\winterframework\web\http\ResponseEntity;
#[RestController]
#[RequestMapping(path: 'media')]
class MediaController
{
#[Autowired]
protected PdbcTemplate $pdbc;
#[Autowired]
protected SqsService $sqs;
#[Value('${media.inbox}')]
protected string $inboxDir;
#[Value('${media.maxBytes}')]
protected int $maxBytes;
#[PostMapping(path: 'upload')]
public function upload(HttpRequest $req): ResponseEntity
{
$file = $req->getFile('image');
if (!$file instanceof HttpUploadedFile || $file->getError() !== UPLOAD_ERR_OK) {
return ResponseEntity::badRequest()->withJson(['error' => 'No image uploaded']);
}
if ($file->getSize() > $this->maxBytes) {
return ResponseEntity::badRequest()->withJson(['error' => 'File too large']);
}
$stored = bin2hex(random_bytes(16)); // random id: client name never touches disk
rename($file->getFilePath(), $this->inboxDir . '/' . $stored);
$this->pdbc->update(
'INSERT INTO media_jobs (source_name, status) VALUES (?, ?)',
[$stored, 'pending']
);
// source_name is random-unique, so reading the id back is exact
$jobId = (int)$this->pdbc->queryForScalar(
'SELECT id FROM media_jobs WHERE source_name = ?',
[$stored]
);
$this->sqs->send('primary', 'media-jobs', ['jobId' => $jobId]);
return ResponseEntity::accepted()->withJson(['success' => true, 'data' => ['jobId' => $jobId]]);
}
#[GetMapping(path: '{id}')]
public function status(#[PathVariable(name: 'id')] int $id): ResponseEntity
{
try {
$row = $this->pdbc->queryForMap(
'SELECT id, status, variants, error FROM media_jobs WHERE id = ?',
[$id]
);
} catch (EmptyResultDataAccessException $e) {
return ResponseEntity::notFound()->build();
}
return ResponseEntity::ok([
'success' => true,
'data' => [
'jobId' => (int)$row['id'],
'status' => $row['status'],
'variants' => json_decode((string)$row['variants'], true) ?? [],
'error' => $row['error'],
],
]);
}
}

Configuration for the directories and budgets (with EnvPropertySource registered — see Configuration):

config/application.yml — additions
media:
inbox: "/var/app/media/inbox"
variants: "/var/app/media/variants"
maxBytes: 10485760 # 10 MiB upload cap
maxPixels: 25000000 # ~25 MP decode cap: decompression-bomb guard

Register the SQS module and point a consumer at media-jobs. The consumer workers start polling as soon as the app boots — nothing else to launch. Switch between the two files:

Enable SqsModule next to the datasource and media: keys in the same file:

modules:
-
module: dev\winterframework\sqs\SqsModule
enabled: true
configFile: sqs-config.yml
  • Serve variants/ as static files from Nginx/CDN — Swoole should not spend worker time on bytes Nginx serves better. A controller download (like the PDF guide) fits only access-controlled originals.
  • Delete the inbox original after success (or archive it if reprocessing matters) — disk fills silently otherwise. A scheduled sweep deleting done rows older than N days plus orphaned files keeps both tidy.
  • Retry policy: the worker marks failed once and the message is gone. A sweeper resetting failed jobs to pending and re-sending their ids to media-jobs (with an attempts cap) covers transient ImageMagick crashes; permanent rejects (bad dimensions) must stay failed.
  • Validate twice. The controller checks size and upload errors; the worker re-validates content (pingImage + pixel budget). Either layer alone is bypassable — together, malformed uploads die before pixels are decoded.
  • Random names everywhere. Client filenames carry traversal (../../), collisions, and encoding quirks. random_bytes() ids plus basename() on every read keep paths inside your directories.
  • Strip metadata by default. Phone photos embed GPS — stripImage() on every public variant. Keep the original’s EXIF only if a feature (e.g. photo maps) explicitly needs it.
  • Memory. Imagick decodes to bitmaps: a 25 MP image needs hundreds of MB transiently. Size worker memory accordingly and keep maxPixels honest.
  1. Start ElasticMQ, create the media-jobs queue, and boot the app — the consumer logs that it started polling.
  2. curl -F image=@photo.jpg http://127.0.0.1:8080/media/upload → 202 with a jobId.
  3. Poll GET /media/{jobId} → pending, then done with three variant names.
  4. Open the variants: 256px square thumb, ratio-kept medium JPEG, WebP copy.
  5. Upload a text file renamed to .jpg → job goes failed, app stays up, error stays generic.
  6. Upload a 50 MP panorama → rejected by the pixel budget before decode.