The missing image component for Symfony.
Inspired by Next.js Image — built for the Symfony ecosystem.
Write one line of Twig. Get AVIF, WebP, responsive srcset, blur placeholders, and lazy loading — automatically.
<!-- You write all of this manually... and maintain it forever -->
<picture>
<source
type="image/avif"
srcset="/images/hero-640.avif 640w, /images/hero-1080.avif 1080w, /images/hero-1920.avif 1920w"
sizes="100vw"
/>
<source
type="image/webp"
srcset="/images/hero-640.webp 640w, /images/hero-1080.webp 1080w, /images/hero-1920.webp 1920w"
sizes="100vw"
/>
<img
src="/images/hero-1080.jpg"
srcset="/images/hero-640.jpg 640w, /images/hero-1080.jpg 1080w, /images/hero-1920.jpg 1920w"
sizes="100vw"
width="1920"
height="1080"
loading="lazy"
alt="Hero"
/>
</picture><Picasso:Image src="hero.jpg" width="1920" height="1080" sizes="100vw" alt="Hero" />Same output. Zero boilerplate. All formats, srcsets, and placeholders generated automatically.
Images account for the largest share of page weight on most websites. Serving them correctly — with modern formats, responsive srcsets, proper lazy loading, and blur placeholders — is critical for both Core Web Vitals and user experience, but the implementation is tedious and error-prone.
PicassoBundle solves this the same way Next.js Image did for React: a single component that handles everything.
| Without PicassoBundle | With PicassoBundle | |
|---|---|---|
| Format negotiation | Manual AVIF/WebP/JPEG <source> tags |
Automatic from config |
| Responsive srcset | Hand-crafted per breakpoint | Generated from sizes prop |
| Blur placeholders | DIY or skip it | Built-in (LQIP, BlurHash, or custom) |
| Dimension detection | Hardcoded or forgotten | Auto-detected from image stream |
| LCP optimization | Manually set loading/fetchpriority | One priority prop |
| Image sources | Filesystem only | Filesystem, S3, Flysystem, Vich, URL |
| CDN support | Build your own integration | Imgix out of the box, or plug in any CDN |
- Why PicassoBundle?
- Features
- Requirements
- Installation
- Quick Start
- Configuration
- Usage
- Placeholders
- Priority Images
- Loaders
- Transformers
- Routes
- How It Works
- Testing & Quality
- Contributing
- License
- One component, full optimization —
<Picasso:Image>renders a complete<picture>with AVIF, WebP, and JPEG sources - Automatic responsive srcset — generates width descriptors for all configured breakpoints, no manual work
- Blur placeholders — built-in LQIP and BlurHash support for instant perceived loading
- Smart dimension detection — reads image dimensions from the stream automatically, preserves aspect ratio
- Priority images — one prop for
loading="eager"+fetchpriority="high"(LCP optimization) - Multiple image sources — Local filesystem, Flysystem (S3, GCS, Azure), VichUploaderBundle, remote URLs
- Local or CDN transforms — Glide for self-hosted, Imgix for CDN, or bring your own
- Signed URLs — HMAC-signed transformation URLs prevent abuse
- PSR-6 metadata caching — dimension detection and BlurHash results cached
- Fully extensible — add custom loaders, transformers, or placeholders
with PHP attributes (
#[AsImageLoader],#[AsImageTransformer],#[AsPlaceholder])
| Dependency | Version |
|---|---|
| PHP | 8.2+ |
| Symfony | 6.4 / 7.0 / 8.0 |
| Symfony UX Twig Component | 2.13+ |
| Package | Required for |
|---|---|
league/glide + league/glide-symfony |
Glide transformer (local image processing) |
kornrunner/blurhash + imagine/imagine |
BlurHash placeholder |
league/flysystem-bundle |
Flysystem loader |
vich/uploader-bundle |
VichUploader loader |
symfony/http-client |
URL loader |
composer require silarhi/picasso-bundleIf not using Symfony Flex, register the bundle in config/bundles.php:
return [
// ...
Silarhi\PicassoBundle\PicassoBundle::class => ['all' => true],
];Install a transformer — at least one is required:
# Option A: Glide (local image transformation)
composer require league/glide league/glide-symfony
# Option B: Imgix (CDN-based transformation)
# No extra package needed, just configure your Imgix base URL1. Configure a loader and a transformer:
# config/packages/picasso.yaml
picasso:
loaders:
filesystem:
paths:
- '%kernel.project_dir%/public/uploads'
transformers:
glide:
sign_key: '%env(PICASSO_SIGN_KEY)%'2. Import the routes (required for Glide local serving):
# config/routes/picasso.yaml
picasso:
resource: '@PicassoBundle/config/routes.php'3. Use the Twig component in your templates:
<Picasso:Image
src="photo.jpg"
width="800"
height="600"
sizes="(max-width: 768px) 100vw, 800px"
alt="A beautiful landscape"
/>This renders a <picture> element with <source> tags for AVIF and
WebP, a fallback <img> with JPEG srcset, and an inline blur
placeholder — all automatically.
When only one loader and one transformer are configured, they are
automatically used as defaults — no need to set default_loader or
default_transformer.
picasso:
loaders:
filesystem:
paths:
- '%kernel.project_dir%/public/uploads'
transformers:
glide:
sign_key: '%env(PICASSO_SIGN_KEY)%'picasso:
# --- Defaults (auto-detected when only one of each type is configured) ---
default_loader: ~
default_transformer: ~
default_placeholder: ~
# --- Responsive breakpoints ---
device_sizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840]
image_sizes: [16, 32, 48, 64, 96, 128, 256, 384]
# --- Output formats (last entry is the <img> fallback) ---
formats: [avif, webp, jpg]
# --- Image quality & fit ---
default_quality: 75 # 1–100
default_fit: contain # contain | cover | crop | fill
# --- Metadata resolution ---
resolve_metadata: false # Whether to auto-detect image dimensions from source (default: false)
# --- Metadata cache ---
cache: true # true = cache.app, false = disabled, or a PSR-6 service ID
# --- Placeholders ---
placeholders:
blur:
type: transformer # inferred from key name when matching a known type
size: 10 # tiny image width/height in px
blur: 5 # blur radius
quality: 30 # JPEG quality for blur image (1–100)
# blurhash:
# type: blurhash
# components_x: 4 # horizontal components (1–9)
# components_y: 3 # vertical components (1–9)
# size: 32 # decoded placeholder image size in px
# driver: gd # gd | imagick
# my_placeholder:
# type: service
# service: 'App\Image\MyPlaceholder'
# --- Loaders ---
loaders:
filesystem:
type: filesystem # inferred from key name
paths:
- '%kernel.project_dir%/public/uploads'
# resolve_metadata: ~ # auto-set to true for filesystem loaders
# my_flysystem:
# type: flysystem
# storage: 'default.storage'
# resolve_metadata: ~ # inherits from global (false)
# vich:
# type: vich
# url:
# type: url
# http_client: ~ # optional: custom PSR-18 HTTP client service ID
# request_factory: ~ # optional: custom PSR-17 request factory service ID
# resolve_metadata: ~ # inherits from global (false)
# --- Transformers ---
transformers:
glide:
type: glide # inferred from key name
sign_key: ~ # signing key for secure URLs
cache: '%kernel.project_dir%/var/glide-cache'
driver: gd # gd | imagick
max_image_size: ~ # optional max pixel count
public_cache:
enabled: false # serve transformed images from public directory
# imgix:
# type: imgix
# base_url: ~ # e.g. https://my-source.imgix.net
# sign_key: ~ # optional signing key
# my_transformer:
# type: service
# service: 'App\Image\MyTransformer'These arrays define which widths are generated in the srcset attribute:
device_sizes— Breakpoint widths for responsive (fluid) images. When the component has asizesattribute, all device and image sizes are merged and included in the srcset.image_sizes— Smaller widths for fixed-size images (icons, thumbnails). When nosizesattribute is provided, srcset includes only1xand2xdescriptors based on the specifiedwidth.
The list of output formats. A <source> element is generated for each
format except the last one, which is used as the <img> fallback. The
default [avif, webp, jpg] produces:
<picture>
<source type="image/avif" srcset="..." />
<source type="image/webp" srcset="..." />
<img src="..." srcset="..." />
<!-- jpg fallback -->
</picture>Supported formats: avif, webp, jpg, jpeg, pjpg, png, gif.
Controls how images are resized within the target dimensions:
| Fit | Description |
|---|---|
contain |
Scales down to fit within the box, preserving aspect ratio (default) |
cover |
Scales to fill the box, cropping excess |
crop |
Crops to exact dimensions |
fill |
Stretches to fill the box exactly |
Controls whether the bundle reads image streams to auto-detect dimensions (width/height).
false(default) — dimensions are not auto-detected; provide them explicitly or accept nowidth/heightin the HTMLtrue— enables auto-detection via theMetadataGuesser
This can also be set per-loader (filesystem loaders default to true) and overridden at runtime with the resolveMetadata component prop or imageData() parameter.
Configures PSR-6 caching for metadata detection (image dimensions) and BlurHash encoding:
true(default) — uses thecache.appservicefalse— disables caching'my_cache_pool'— uses a custom PSR-6 cache pool service ID
Tip: The
typeoption for loaders, transformers, and placeholders is automatically inferred from the key name when it matches a known type (filesystem,flysystem,vich,url,glide,imgix,transformer,blurhash). Usetypeexplicitly only when your key name differs from the type.
The <Picasso:Image> component renders a responsive <picture> element
with <source> tags for each configured format and a fallback <img>
with a full srcset.
<Picasso:Image
src="photo.jpg"
width="800"
height="600"
sizes="(max-width: 768px) 100vw, 800px"
alt="A beautiful landscape"
/>| Property | Type | Default | Description |
|---|---|---|---|
src |
string |
— | Image path relative to the loader's base |
width |
int |
auto | Display width (auto-detected from source) |
height |
int |
auto | Display height (auto-detected from source) |
sizes |
string |
— | Responsive sizes attribute |
sourceWidth |
int |
auto | Explicit source width (skips detection) |
sourceHeight |
int |
auto | Explicit source height (skips detection) |
loader |
string |
— | Override default loader |
transformer |
string |
— | Override default transformer |
quality |
int |
75 | Override quality (1–100) |
fit |
string |
contain | Fit mode: contain, cover, crop, fill |
placeholder |
string|bool |
— | true/false to enable/disable, or a placeholder name |
placeholderData |
string |
— | Literal data URI, bypasses placeholder services |
priority |
bool |
false | Eager loading, fetchpriority="high", no placeholder |
loading |
string |
lazy | lazy or eager. Auto-set when priority |
fetchPriority |
string |
— | high, low, auto. Auto-set when priority |
unoptimized |
bool |
false | Serve original image without transformation |
resolveMetadata |
bool |
— | Override metadata resolution (see below) |
context |
array |
[] |
Extra context for the loader (e.g. Vich) |
When width and height are not provided, PicassoBundle can
detect them from the image stream. You can also provide sourceWidth
and sourceHeight to skip detection entirely, which is useful for
performance when you already know the image dimensions:
{# Auto-detected dimensions (requires resolve_metadata enabled) #}
<Picasso:Image src="photo.jpg" sizes="100vw" alt="Photo" />
{# Explicit source dimensions (skips stream detection) #}
<Picasso:Image src="photo.jpg" :sourceWidth="4000" :sourceHeight="3000" width="800" height="600" alt="Photo" />The component also preserves aspect ratio when only one display dimension is provided:
{# height is calculated automatically from the source aspect ratio #}
<Picasso:Image src="photo.jpg" width="800" sizes="100vw" alt="Photo" />To reduce Cumulative Layout Shift (CLS), width and height
attributes are only rendered in the HTML when both are available.
If only one dimension is provided and the other cannot be resolved,
neither is output — preventing the browser from reserving incorrect
space.
Metadata resolution (reading the image stream to detect dimensions) is controlled at three levels, with this precedence: runtime > per-loader > global.
| Level | Option | Default |
|---|---|---|
| Global | resolve_metadata |
false |
| Per-loader | resolve_metadata |
null (inherit global); true for filesystem |
| Runtime | resolveMetadata |
null (inherit per-loader/global) |
{# Force metadata resolution for this image, regardless of config #}
<Picasso:Image src="photo.jpg" :resolveMetadata="true" width="800" sizes="100vw" alt="Photo" />
{# Disable metadata resolution for this image #}
<Picasso:Image src="photo.jpg" :resolveMetadata="false" width="800" height="600" alt="Photo" />Filesystem loaders default to resolve_metadata: true because reading
local files is cheap. For remote loaders (URL, Flysystem with remote
backends), it defaults to false to avoid unnecessary network requests.
The component forwards any extra attributes to the inner <img> tag:
<Picasso:Image
src="photo.jpg"
width="400"
height="300"
class="rounded shadow-lg"
id="main-photo"
data-controller="lightbox"
alt="Photo"
/>Use unoptimized to serve the image as-is, without any transformation. The src value is passed directly to the <img> tag:
<Picasso:Image src="/images/logo.svg" :unoptimized="true" alt="Logo" />The picasso_image_url() function generates a single transformed image URL.
Useful for backgrounds, meta tags, Open Graph images, or anywhere you need a plain URL.
{# Simple thumbnail #}
<img src="{{ picasso_image_url('photo.jpg', width: 300, format: 'webp') }}" alt="Thumbnail">
{# Open Graph meta tag #}
<meta property="og:image" content="{{ picasso_image_url('hero.jpg', width: 1200, height: 630, format: 'jpg', fit: 'cover') }}">
{# CSS background image #}
<div style="background-image: url('{{ picasso_image_url('bg.jpg', width: 1920, format: 'webp', quality: 80) }}')">All available parameters:
{{ picasso_image_url(
'photo.jpg',
width: 800,
height: 600,
format: 'webp',
quality: 85,
fit: 'cover',
blur: 10,
dpr: 2,
loader: 'vich',
transformer: 'imgix',
context: { entity: product, field: 'imageFile' }
) }}| Parameter | Type | Description |
|---|---|---|
width |
int |
Target width in pixels |
height |
int |
Target height in pixels |
format |
string |
Output format (avif, webp, jpg, etc.) |
quality |
int |
Output quality (1–100) |
fit |
string |
Fit mode (contain, cover, crop, fill) |
blur |
int |
Blur radius |
dpr |
int |
Device pixel ratio |
loader |
string |
Override default loader |
transformer |
string |
Override default transformer |
context |
array |
Extra context for the loader |
The picasso_image_url() Twig function delegates to ImageHelperInterface, which you can also inject directly in your PHP code:
use Silarhi\PicassoBundle\Service\ImageHelperInterface;
class MyController
{
public function __construct(private ImageHelperInterface $imageHelper) {}
public function index(): Response
{
$url = $this->imageHelper->imageUrl(
path: 'photo.jpg',
width: 300,
format: 'webp',
);
// ...
}
}The imageData() method returns an ImageRenderData DTO containing all
rendering data (sources, srcset, placeholder, dimensions, loading attributes).
It implements JsonSerializable, making it ideal for headless / API-driven
frontends (React, Vue, mobile apps, etc.):
use Silarhi\PicassoBundle\Service\ImageHelperInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
class ImageApiController
{
public function __construct(private ImageHelperInterface $imageHelper) {}
public function show(): JsonResponse
{
$data = $this->imageHelper->imageData(
src: 'hero.jpg',
width: 1200,
height: 800,
sizes: '100vw',
placeholder: true,
);
return new JsonResponse($data);
}
}The JSON response contains everything a frontend needs to render a responsive <picture> element:
{
"fallbackSrc": "/image/glide/filesystem/hero.jpg?w=1200&h=800&fm=jpg&s=...",
"fallbackSrcset": "/image/glide/.../hero.jpg?w=640&fm=jpg&s=... 640w, ... 1920w",
"sources": [
{ "type": "image/avif", "srcset": "..." },
{ "type": "image/webp", "srcset": "..." }
],
"placeholderUri": "data:image/jpeg;base64,...",
"width": 1200,
"height": 800,
"loading": "lazy",
"fetchPriority": null,
"sizes": "100vw",
"unoptimized": false,
"attributes": {}
}imageData() accepts the same parameters as the <Picasso:Image> Twig component (src, width, height, sizes, quality, fit, placeholder, priority, loader, transformer, etc.).
Placeholders generate a low-quality preview displayed while the full image loads.
The placeholder is inlined as a CSS background-image on the <img> tag and
automatically removed via an onload handler once the full image has loaded.
The transformer placeholder generates a tiny blurred version of the image using your configured transformer (Glide or Imgix). This is the simplest placeholder to set up — it requires no extra dependencies.
picasso:
default_placeholder: blur
placeholders:
blur:
type: transformer
size: 10 # tiny image width/height in px
blur: 5 # blur radius
quality: 30 # JPEG quality (1–100)The BlurHash placeholder encodes the image as a BlurHash string and decodes it to a tiny PNG data URI. This produces a smooth gradient-like preview that is very small (around 20–30 bytes as a hash).
composer require kornrunner/blurhash imagine/imaginepicasso:
default_placeholder: blurhash
placeholders:
blurhash:
type: blurhash
components_x: 4 # horizontal components (1–9, higher = more detail)
components_y: 3 # vertical components (1–9, higher = more detail)
size: 32 # decoded placeholder image size in px
driver: gd # gd | imagickYou can create your own placeholder by implementing PlaceholderInterface:
use Silarhi\PicassoBundle\Attribute\AsPlaceholder;
use Silarhi\PicassoBundle\Placeholder\PlaceholderInterface;
use Silarhi\PicassoBundle\Dto\Image;
use Silarhi\PicassoBundle\Dto\ImageTransformation;
#[AsPlaceholder('thumbhash')]
class ThumbHashPlaceholder implements PlaceholderInterface
{
public function generate(Image $image, ImageTransformation $transformation, array $context = []): string
{
// Generate and return a data URI
return 'data:image/png;base64,...';
}
}Or register it via configuration:
picasso:
default_placeholder: thumbhash
placeholders:
thumbhash:
type: service
service: 'App\Image\ThumbHashPlaceholder'{# Uses the default placeholder from config #}
<Picasso:Image src="photo.jpg" width="800" height="600" sizes="100vw" alt="Photo" />
{# Disable placeholder for this image #}
<Picasso:Image src="icon.png" width="64" height="64" :placeholder="false" />
{# Select a specific named placeholder #}
<Picasso:Image src="hero.jpg" width="1200" height="800" placeholder="blurhash" />
{# Pass a literal data URI directly (bypasses all placeholder services) #}
<Picasso:Image src="photo.jpg" width="800" height="600" placeholderData="data:image/png;base64,..." />For above-the-fold images (hero banners, LCP images), use the priority prop.
This sets loading="eager", fetchpriority="high", and disables the blur
placeholder for optimal Largest Contentful Paint (LCP) performance:
<Picasso:Image
src="hero-banner.jpg"
width="1920"
height="1080"
sizes="100vw"
:priority="true"
alt="Hero banner"
/>Note: Placeholders are automatically disabled when
priorityistrue, since priority images should load immediately without showing a placeholder first.
Loaders fetch image data from a source. Each loader implements ImageLoaderInterface and is registered by name.
Reads images from local directories. Supports multiple paths (searched in order).
picasso:
loaders:
filesystem:
paths:
- '%kernel.project_dir%/public/uploads'
- '%kernel.project_dir%/assets/images'<Picasso:Image src="photos/landscape.jpg" width="800" height="600" alt="Landscape" />Reads images via a Flysystem storage, supporting S3, GCS, Azure, and more.
composer require league/flysystem-bundlepicasso:
loaders:
my_s3:
type: flysystem
storage: 'default.storage' # your Flysystem service ID<Picasso:Image src="photo.jpg" loader="my_s3" width="800" height="600" alt="S3 image" />Loads images managed by VichUploaderBundle.
composer require vich/uploader-bundlepicasso:
loaders:
vich: ~ # type inferred from key name<Picasso:Image
src="product-photo.jpg"
:context="{ entity: product, field: 'imageFile' }"
width="400"
height="300"
alt="Product image"
/>The context must include the entity (the Doctrine entity instance) and field (the VichUploader mapping field name).
Loads and transforms remote images by URL. Requires a PSR-18 HTTP client.
composer require symfony/http-clientpicasso:
loaders:
url: ~ # type inferred from key name<Picasso:Image
src="https://example.com/remote-image.jpg"
loader="url"
width="800"
height="600"
alt="Remote image"
/>Create a custom loader by implementing ImageLoaderInterface and tagging it with #[AsImageLoader]:
use Silarhi\PicassoBundle\Attribute\AsImageLoader;
use Silarhi\PicassoBundle\Loader\ImageLoaderInterface;
use Silarhi\PicassoBundle\Dto\Image;
use Silarhi\PicassoBundle\Dto\ImageReference;
#[AsImageLoader('s3')]
class S3Loader implements ImageLoaderInterface
{
public function load(ImageReference $reference, bool $withMetadata = false): Image
{
// Fetch from S3, return an Image DTO
}
}If your loader provides direct filesystem access for local transformers (like Glide), implement ServableLoaderInterface instead.
Transformers generate URLs for on-demand image transformation.
Glide processes images locally using GD or Imagick.
composer require league/glide league/glide-symfonypicasso:
transformers:
glide:
sign_key: '%env(PICASSO_SIGN_KEY)%'
cache: '%kernel.project_dir%/var/glide-cache'
driver: gd # gd | imagick
max_image_size: ~ # optional: max pixel count (width x height)
public_cache:
enabled: false # serve from public dir for better performanceImportant: When using Glide, you must import the bundle routes so that the image controller can serve transformed images.
Imgix processes images via their CDN. No local processing is needed.
picasso:
transformers:
imgix:
base_url: 'https://my-source.imgix.net'
sign_key: '%env(IMGIX_SIGN_KEY)%' # optionalCreate a custom transformer by implementing ImageTransformerInterface:
use Silarhi\PicassoBundle\Attribute\AsImageTransformer;
use Silarhi\PicassoBundle\Transformer\ImageTransformerInterface;
use Silarhi\PicassoBundle\Dto\Image;
use Silarhi\PicassoBundle\Dto\ImageTransformation;
#[AsImageTransformer('cloudinary')]
class CloudinaryTransformer implements ImageTransformerInterface
{
public function url(Image $image, ImageTransformation $transformation, array $context = []): string
{
// Build and return a Cloudinary URL
}
}Or register it via configuration:
picasso:
transformers:
cloudinary:
type: service
service: 'App\Image\CloudinaryTransformer'The bundle registers a route for on-demand image transformation (used by Glide and other local transformers):
GET /image/{transformer}/{loader}/{path}
Import the routes in your application:
# config/routes/picasso.yaml
picasso:
resource: '@PicassoBundle/config/routes.php'Note: Routes are only required when using a local transformer like Glide. CDN-based transformers (Imgix) generate external URLs and do not need this route.
When you use <Picasso:Image>, the component:
- Loads the image metadata via the configured loader (filesystem, Flysystem, Vich, URL)
- Detects dimensions from the image stream (or uses explicitly provided values)
- Generates srcset entries for each configured format at all responsive breakpoints
- Generates a placeholder (if configured) — a tiny blurred image inlined as a CSS background
- Renders a
<picture>element with<source>tags per format and a fallback<img>
The generated HTML follows modern best practices:
<source>elements for modern formats (AVIF, WebP) with automatic MIME type detection- Full
srcsetwith width descriptors for responsive loading sizesattribute for accurate viewport-based selectionloading="lazy"by default for below-the-fold images- Blur placeholder with CSS
background-imageandonloadcleanup
# Install dependencies
composer install
# Run tests
vendor/bin/phpunit
# Static analysis (level: max)
vendor/bin/phpstan analyse
# Code style check
vendor/bin/php-cs-fixer fix --dry-run --diff
# Code style fix
vendor/bin/php-cs-fixer fix
# Twig code style
vendor/bin/twig-cs-fixer lint
# Code modernization check
vendor/bin/rector process --dry-runContributions are welcome! Please make sure your changes pass all quality checks before submitting a pull request:
vendor/bin/phpunit && vendor/bin/phpstan analyse && vendor/bin/php-cs-fixer fix --dry-run --diffMIT License. See LICENSE for details.
Built with care by SILARHI.
If PicassoBundle saves you time, consider giving it a star on GitHub.