Upload an image, get it back resized, converted, compressed, rotated or in sepia. Symfony 7.4 + Imagick behind a small API, with a TypeScript + FilePond frontend, PL/EN. file-actions.tolemak.pl
cd container
docker compose up -d --build
docker compose exec web-server composer install
cd .. && npm install && npm run buildApp on http://localhost:40055. The default image is production; for Xdebug (port 9000, matches .vscode/launch.json) add -f docker-compose.yml -f docker-compose.dev.yml.
Without Docker: PHP 8.3 with ext-imagick, then composer install, npm run build and php -S 127.0.0.1:8000 -t public. .env defaults to prod, set APP_ENV=dev in .env.local for the profiler.
Production image: after the checks pass on master, CI builds the whole runtime in container/Dockerfile.app (Composer vendor, Vite assets, Apache on port 8080 as a non-root user), pushes it as ghcr.io/tolemak/fileactions-app:<commit sha> (and :latest) and attaches a signed build provenance attestation. The server pulls the image and verifies the attestation itself; CI never connects to it.
A single image (jpeg/png/gif, up to 5 MB) as multipart/form-data, the processed file comes back as a download:
POST /file/resize/{size} 10-300 (%)
POST /file/convert/{extension} jpeg | png | gif
POST /file/compress/{ratio} 1-100
POST /file/rotate/{degrees} 0-360
POST /file/sepia/{intensity} 1-100
Images are rejected above 24 megapixels (PixelBudget::MAX_PIXELS): a 5 MB upload can still decode to hundreds of megapixels, so dimensions are read from the header before decoding. At 8 bytes per pixel (Q16) this stays well under the 256 MB Imagick memory limit set in FileService.
An action is one PHP class in src/Action/ that implements App\Action\FileAction. Symfony autoconfigures it with the app.file_action tag, and everything else is generic: the POST /file/{action}[/{value}] route, the /file-view/{action} page, the navigation entry, the home card and the single TypeScript sub-app, which reads the option schema from data-* attributes.
final class BlurAction implements FileAction
{
public function name(): string { return 'blur'; }
public function spec(): string { return '1-20'; }
public function option(): ActionOption { return ActionOption::range(1, 20, 1, 5); }
public function process(Imagick $image, int|string|null $value): void
{
$image->blurImage((int) $value, 1);
}
}option()returnsActionOption::range($min, $max, $step, $default)(a slider prompt),ActionOption::choice(['value' => 'Label'])(a select on the page) ornullfor an action without a parameter (POST /file/{action}).- An action that changes the output format also implements
ChangesOutputFormat, seeConvertAction. - Optional
#[AsTaggedItem(priority: n)]sets the position in the navigation; without it the action comes last. - Translations, in
translations/messages.en.yamlandmessages.pl.yaml:tools.{name},home.card.{name}.text,{name}.title,{name}.header,{name}.description,{name}.button,error.{name}_failed, and for an action with an optionerror.{name}_invalidplus{name}.prompt_title(range) or{name}.option_label(choice).
That is the whole change: one class and two translation blocks. tests/Fixtures/GrayscaleAction.php is exactly such an action, registered only in the test environment (when@test in config/services.yaml), and tests/Action/AddingAnActionTest.php proves it gets a route, a page, a navigation entry and a home card with no other code.
npm run build # page tests need the built assets
php bin/phpunit
npm testTypeScript 7 waits for typescript-eslint support, so Dependabot skips TypeScript major updates until then.