Private images
The bundle route serves any image whose URL is signed: the signature proves the URL was generated by your application, not that the visitor may see the image. For private content (invoices, contracts, documents of a customer space), serve the images from your own routes instead, where your firewall and security voters decide, while Picasso keeps rendering the <picture>: srcset, formats, placeholder.
- Browser/portal/acme/documents/42/image?w=640&fm=webp&s=…
- Your routefirewall + #[IsGranted]
- ImageServerchecks the signature against this image
- Gliderenders or serves from cache
Setting it up
Section titled “Setting it up”-
Mark the loader
private. The bundle route then refuses its images, and rendering one without a route fails, so no URL can bypass your routes:picasso:loaders:documents: { type: vich, mapping: document_file, private: true }transformers:glide: { sign_key: '%env(PICASSO_SIGN_KEY)%' } # no public_cacheLoaders registered with the attribute take it as
#[AsImageLoader('documents', private: true)]. -
Serve the images from your route with
ImageServer. Pass it the image (here the entity your voter just authorized): the URL only says which transformation to render, and its signature must match that image.use Silarhi\PicassoBundle\Dto\ImageReference;use Silarhi\PicassoBundle\Service\ImageServer;#[Route('/portal/{tenant}/documents/{id}/image', name: 'portal_document_image', methods: ['GET'])]#[IsGranted('TENANT_DOCUMENT_VIEW', 'document')]public function image(Document $document, Request $request, ImageServer $images): Response{return $images->serve($request, new ImageReference(context: ['entity' => $document]), loader: 'documents');} -
Render with that route. Every URL of the
<picture>(each srcset candidate of each format, the fallback, the transformer placeholder) points at it, with the transformation and its signature in the query string:<twig:Picasso:Image context="{{ {entity: document} }}" loader="documents" sizes="(min-width: 768px) 33vw, 100vw"route="portal_document_image" routeParameters="{{ {tenant: tenant.slug, id: document.id} }}" />{# → /portal/acme/documents/42/image?w=640&fm=webp&q=75&fit=contain&s=… #}picasso_image(),picasso_image_url()andImageHelperInterfacetake the samerouteandrouteParametersarguments.
Each of your routes guards the same images its own way: give every route its own action calling ImageServer, and render each page with its route. They share one Glide cache, since a variant is cached by image and transformation, not by URL.
ImageServer::serve()answersCache-Control: private, no-cache: browsers keep the image, but ask your route again before reusing it, so a revoked access takes effect at once, and shared caches (CDNs, proxies) never store it. Change the headers on the returned response if needed.- It throws a
NotFoundHttpExceptionwhen the image is missing or cannot be decoded, or when the signature does not match the image (a tampered transformation, or the URL of another image), never cacheable. - It accepts a path (
$images->serve($request, 'contracts/42.jpg', 'private_files')) as well as anImageReference. The transformer defaults like when rendering: the loader’sdefault_transformer, else the global one. - Only local transformers (Glide) serve routes; a route with Imgix, or with a Glide transformer using
public_cache(whose files the web server serves without your route), throws anInvalidRouteException, as do route parameters named like a transformation param (w,h,fm,q,fit,blur,dpr),sor_fragment.base_urlis not applied to these URLs: they belong to your application. - Route parameters that are not placeholders of the route path end up in the query string, and are signed with it.
- Keep the originals out of
public/: a private loader only protects what the web server does not serve directly.
