Skip to content

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.

  1. Browser/portal/acme/documents/42/image?w=640&fm=webp&s=…
  2. Your routefirewall + #[IsGranted]
  3. ImageServerchecks the signature against this image
  4. Gliderenders or serves from cache
Every URL of the <picture> points at your route: your voter decides, then Picasso serves the variant.
  1. 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_cache

    Loaders registered with the attribute take it as #[AsImageLoader('documents', private: true)].

  2. 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');
    }
  3. 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() and ImageHelperInterface take the same route and routeParameters arguments.

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() answers Cache-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 NotFoundHttpException when 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 an ImageReference. The transformer defaults like when rendering: the loader’s default_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 an InvalidRouteException, as do route parameters named like a transformation param (w, h, fm, q, fit, blur, dpr), s or _fragment. base_url is 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.