Ir al contenido
getnextpdf.com

Entregar un PDF generado mediante una URL firmada con caducidad

Generas un archivo en formato de documento portátil (PDF) y necesitas entregarlo a un cliente. La vía más sencilla pasa los bytes directamente a través de un controlador, pero eso ocupa un worker de la aplicación durante toda la descarga, hace pasar el tráfico por tus servidores y expone el archivo a cualquiera que pueda alcanzar la ruta. El patrón de entrega de esta página hace lo contrario: genera el PDF, almacena los bytes en almacenamiento de objetos y devuelve un Localizador Uniforme de Recursos (URL) firmado de corta vida que el cliente obtiene directamente del almacenamiento. Tu app devuelve una pequeña carga útil de Notación de Objetos de JavaScript (JSON) con una URL; el almacenamiento sirve los bytes.

El lado de NextPDF es una sola llamada: getPdfData() en el documento devuelve el binario PDF en bruto como una cadena. Todo lo posterior —colocar el objeto y acuñar un enlace firmado con tiempo limitado— es tarea de tu framework o de tu proveedor de nube. Las primitivas de firma son API reales y documentadas: Laravel Storage::temporaryUrl() y URL::temporarySignedRoute(), Symfony UriSigner, y las operaciones de URL prefirmada de Amazon Simple Storage Service (S3) o Google Cloud Storage (GCS) en sus kits de desarrollo de software (SDK). NextPDF no define ningún ayudante de URL propio; no busques uno.

Comprueba primero estas piezas:

  • El core de NextPDF está instalado y puedes construir un documento.
  • Tienes almacenamiento de objetos para el que el framework puede firmar: un bucket de S3 o compatible con S3, un bucket de GCS, o un disco de Laravel cuyo driver admita URL temporales.
  • Las credenciales viven en variables de entorno o en un gestor de secretos, nunca en configuración confirmada.

Este es un tutorial práctico. Asume que ya sabes cómo enrutar una petición hacia un controlador. Para devolver bytes directamente en su lugar, consulta Devuelve un PDF generado desde un controlador.

El patrón tiene tres pasos, y solo el primero toca NextPDF:

  1. Generar. Construye el documento y llama a getPdfData() para obtener los bytes.
  2. Almacenar. Escribe esos bytes en una clave de almacenamiento de objetos (reports/2026/r-42.pdf).
  3. Firmar. Pide al framework o al SDK de nube una URL firmada hacia esa clave, con una caducidad, y devuelve la URL al cliente.

Por qué almacenar y firmar en lugar de pasar los bytes:

  • Descarga de ancho de banda. El almacenamiento de objetos (o el edge de su red de distribución de contenido) sirve la descarga. Tu worker de aplicación devuelve unos cientos de bytes de JSON y queda libre de inmediato, en lugar de quedar retenido durante una transferencia de varios megabytes.
  • Acota el acceso. Una URL firmada concede acceso a un objeto durante una ventana acotada. El bucket en sí permanece privado. No hay ruta pública que forzar por fuerza bruta ni concesión amplia de lectura del bucket.
  • Caducidad. La firma incrusta una marca de tiempo de caducidad. Cuando pasa, el enlace está muerto. Una URL filtrada deja de funcionar por sí sola, lo que acota el radio de impacto de un compartido accidental.

Hay dos modelos de firma distintos, y difieren en qué se firma:

  • URL prefirmadas de almacenamiento de objetos (S3, GCS, o el temporaryUrl() de Laravel sobre un disco S3/GCS) apuntan directamente al objeto de almacenamiento. La descarga no llega en absoluto a tu app.
  • Rutas firmadas de aplicación (Laravel URL::temporarySignedRoute(), Symfony UriSigner) apuntan a tu propia ruta. La petición sí llega a tu app, que verifica la firma y luego pasa por streaming o redirige al objeto. Úsalas cuando necesites ejecutar autorización, registro o contabilidad en cada descarga, o cuando tu almacenamiento no pueda prefirmar.
AspectoNextPDFLaravelSymfony
Obtener bytes del PDFNextPDF\Core\Document::getPdfData(): stringigualigual
Almacenar bytesStorage::disk($d)->put($key, $bytes)Filesystem::dumpFile($path, $bytes) o Flysystem write()
URL prefirmada de almacenamientoStorage::disk($d)->temporaryUrl($key, $expiresAt)firmador del SDK de AWS/GCS (abajo)
Ruta firmada de appURL::temporarySignedRoute($name, $expiresAt, $params)UriSigner::sign($url)
Verificar una ruta firmada de appmiddleware de ruta signed / $request->hasValidSignature()UriSigner::check() / checkRequest()

La única LLAMADA al motor de NextPDF que requiere este patrón de entrega es getPdfData(); el documento en sí se construye como ya construya documentos tu app (p. ej., el DocumentFactoryInterface inyectado / el PdfFactory de Symfony). getPdfData() se declara en el trait HasOutput sobre NextPDF\Core\Document. Llama al escritor una vez y devuelve el PDF entero como una cadena. Su equivalente save(string $path): void escribe los mismos bytes en disco mediante un escritor atómico; úsalo solo cuando tu almacenamiento sea una ruta de sistema de archivos local real. Para almacenamiento de objetos, prefiere getPdfData() y deja que el SDK de almacenamiento se encargue de la transferencia.

El documento se construye cuando llamas a getPdfData() (o save()), y la construcción no es idempotente. Llámalo una vez por documento, captura la cadena y reutiliza esa cadena tanto para la subida como para cualquier tamaño o suma de comprobación que calcules.

Ejemplo de código — URL temporal de Laravel

Sección titulada «Ejemplo de código — URL temporal de Laravel»

La abstracción de sistema de archivos de Laravel firma por ti. En un disco S3 (o compatible con S3), Storage::temporaryUrl() devuelve una URL prefirmada directamente al objeto. El cliente descarga del almacenamiento; tu acción devuelve solo JSON.

app/Http/Controllers/ReportDeliveryController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;
use NextPDF\Contracts\DocumentFactoryInterface;
use Psr\Log\LoggerInterface;
use Throwable;
final class ReportDeliveryController extends Controller
{
public function __construct(
private readonly DocumentFactoryInterface $documents,
private readonly LoggerInterface $logger,
) {}
public function store(int $reportId): JsonResponse
{
try {
// 1. Generate. Build once; getPdfData() returns the raw bytes.
$document = $this->documents->create();
$document->addPage();
$document->cell(0, 10, "Report #{$reportId}", newLine: true);
$bytes = $document->getPdfData();
// 2. Store under a non-guessable key on a private disk.
$key = sprintf('reports/%d/%s.pdf', $reportId, bin2hex(random_bytes(16)));
Storage::disk('s3')->put($key, $bytes, ['visibility' => 'private']);
// 3. Sign. A presigned URL straight to the object, valid 10 minutes.
$url = Storage::disk('s3')->temporaryUrl($key, now()->addMinutes(10));
return new JsonResponse(['download_url' => $url], 201);
} catch (Throwable $exception) {
// Log the class, never the message or trace, so detail does not leak.
$this->logger->error('Report PDF delivery failed', [
'report_id' => $reportId,
'exception' => $exception::class,
]);
return new JsonResponse(['error' => 'Could not prepare the report.'], 500);
}
}
}

El disco debe ser uno cuyo driver admita URL temporales: el driver s3 incluido lo hace. Llamar a temporaryUrl() en el driver local lanza una excepción a menos que registres un generador para él, porque un disco local no tiene nada que prefirmar.

Cuando prefieras mantener la descarga en tu propia ruta —para ejecutar autorización por petición o registrar cada acceso—, firma una ruta en su lugar con URL::temporarySignedRoute(). El middleware signed de la ruta rechaza un enlace manipulado o caducado antes de que se ejecute tu acción.

routes/web.php
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
// Mint the link elsewhere:
// URL::temporarySignedRoute('reports.download', now()->addMinutes(10),
// ['report' => $reportId]);
Route::get('/reports/{report}/download', DownloadReportController::class)
->name('reports.download')
->middleware('signed');

Symfony no tiene una fachada de almacenamiento al estilo de Laravel, así que firmas tu propia ruta con el Symfony\Component\HttpFoundation\UriSigner del framework, y luego haces que esa ruta redirija a una URL de almacenamiento prefirmada (o pase el objeto por streaming). UriSigner::sign() añade un hash con clave; checkRequest() rechaza un enlace manipulado. Para mantener el ejemplo portable entre versiones de Symfony, incrusta tu propio parámetro de consulta expires (una marca de tiempo Unix unos minutos en el futuro) antes de firmar, y luego valida ese parámetro tú mismo en la ruta download después de que la firma se compruebe correctamente. Esto funciona en todas las versiones de Symfony, porque UriSigner::sign(string $uri) recibe solo la URL.

src/Controller/ReportDeliveryController.php
<?php
declare(strict_types=1);
namespace App\Controller;
use NextPDF\Symfony\Service\PdfFactory;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final class ReportDeliveryController
{
// 1 + 2 + sign: build, store, and return a signed URL to our own route.
#[Route('/reports/{reportId}', name: 'report_prepare', methods: ['POST'])]
public function prepare(
int $reportId,
PdfFactory $pdf,
UriSigner $signer,
UrlGeneratorInterface $urls,
ReportStorage $storage, // your storage adapter
): JsonResponse {
$document = $pdf->create();
$document->addPage();
$document->cell(0, 10, "Report #{$reportId}", newLine: true);
$key = $storage->put($reportId, $document->getPdfData());
$url = $urls->generate(
'report_download',
['reportId' => $reportId, 'key' => $key],
UrlGeneratorInterface::ABSOLUTE_URL,
);
// Embed our own expiry (a Unix timestamp 10 minutes out), then sign the
// URL only. UriSigner::sign(string $uri) is portable across all versions.
$url .= (str_contains($url, '?') ? '&' : '?')
. 'expires=' . ((new \DateTimeImmutable('+10 minutes'))->getTimestamp());
return new JsonResponse(['download_url' => $signer->sign($url)]);
}
// verify: the signed route. checkRequest() rejects a tampered link; then we
// enforce the embedded expiry ourselves.
#[Route('/reports/{reportId}/download', name: 'report_download', methods: ['GET'])]
public function download(
Request $request,
UriSigner $signer,
ReportStorage $storage,
): Response {
if (!$signer->checkRequest($request)) {
return new Response('Link invalid.', 403);
}
// Enforce the embedded expiry: reject once the timestamp is in the past.
$expires = (int) $request->query->get('expires');
if ($expires < time()) {
return new Response('Link expired.', 410);
}
// Redirect to a presigned storage URL, or stream the object here.
return new Response('', 302, ['Location' => $storage->presign(
(string) $request->query->get('key'),
)]);
}
}

UriSigner se construye con un secreto (Symfony lo autoconecta desde el parámetro %kernel.secret% / APP_SECRET). El ejemplo anterior es la vía portable: UriSigner::sign(string $uri) firma solo la URL y existe en todas las versiones de Symfony, así que la caducidad viaja como tu propio parámetro de consulta expires. La firma cubre ese parámetro, de modo que no puede manipularse; y, después de que checkRequest() pase, la ruta download lo impone comparando la marca de tiempo con la hora actual y devolviendo 410 Gone una vez que ha quedado en el pasado.

En las versiones de Symfony cuyo UriSigner::sign() acepta un argumento de caducidad DateTimeInterface, puedes pasar la caducidad directamente —$signer->sign($url, new \DateTimeImmutable('+10 minutes'))— y dejar que checkRequest() rechace por ti los enlaces caducados, suprimiendo el parámetro manual expires y su comprobación. Confirma la firma de UriSigner::sign() en tu Symfony instalado antes de confiar en ello; el patrón portable de arriba funciona en cualquier caso.

Ejemplo de código — URL prefirmada con el SDK de nube

Sección titulada «Ejemplo de código — URL prefirmada con el SDK de nube»

Si firmas directamente con un SDK de nube en lugar de a través de un disco del framework, la forma es la misma: coloca el objeto y luego pide al SDK que prefirme un GET para él. Esto es S3 sin más (el flujo de GCS lo refleja: obtén el objeto con $bucket->object($key) y llama a $object->signedUrl($expiresAt, [...])).

store-and-presign.php
<?php
declare(strict_types=1);
use Aws\S3\S3Client;
use NextPDF\Core\Document;
/** @var Document $document Already built by your generation code. */
$bytes = $document->getPdfData(); // NextPDF: the only engine call.
$s3 = new S3Client(['region' => 'eu-central-1', 'version' => 'latest']);
$key = 'reports/' . bin2hex(random_bytes(16)) . '.pdf';
// Store the object privately.
$s3->putObject([
'Bucket' => 'my-private-reports',
'Key' => $key,
'Body' => $bytes,
'ContentType' => 'application/pdf',
]);
// Presign a GET valid for 10 minutes. The returned URI is the signed URL.
$command = $s3->getCommand('GetObject', [
'Bucket' => 'my-private-reports',
'Key' => $key,
]);
$signedUrl = (string) $s3->createPresignedRequest($command, '+10 minutes')->getUri();

Para GCS, construye los bytes de la misma manera con getPdfData(), sube el objeto con el cliente de Cloud Storage, luego obtén el objeto de almacenamiento con $bucket->object($key) y llama a $object->signedUrl($expiresAt, [...]) con una caducidad Carbon/DateTime para acuñar el enlace equivalente. La caducidad de la URL firmada en ambos proveedores está acotada por el tipo de credencial; consulta la documentación del proveedor para conocer la vida máxima que permiten tus credenciales.

  • Construye el documento exactamente una vez. getPdfData() dispara la construcción, y la construcción no es idempotente. Llámalo una vez, conserva la cadena y reutilízala tanto para la subida como para cualquier Content-Length, suma de comprobación o ETag que calcules. No lo llames de nuevo para «releer» los bytes.
  • temporaryUrl() necesita un driver capaz de prefirmar. El driver s3 de Laravel prefirma; el driver local lanza una excepción en temporaryUrl() a menos que registres un generador personalizado con Storage::disk('local')->buildTemporaryUrlsUsing(...). Elige un disco que pueda firmar, o firma una ruta de app en su lugar.
  • Establece el tipo de contenido del objeto. Almacena con Content-Type: application/pdf (la opción de subida ContentType, o los metadatos del disco) para que el navegador abra el enlace prefirmado como un PDF en lugar de descargar un octet-stream.
  • Una caducidad corta puede sobrevivir menos que un cliente lento. Si el usuario hace clic en el enlace bastante después de que lo acuñes, una ventana de 60 segundos puede estar ya muerta. Dimensiona la caducidad según el hueco realista entre la acuñación y el primer byte —minutos, no segundos— y reacuña bajo demanda en lugar de estirarla hasta horas.
  • Una URL firmada es acceso al portador. Cualquiera que tenga la URL antes de que caduque puede descargar el objeto. Mantén las caducidades cortas, prefiere el alcance de un solo objeto y nunca registres la URL firmada completa: la firma es efectivamente un token.
  • No incrustes entrada de usuario sin sanear en la clave del objeto. Construye las claves a partir de valores que controles más bytes aleatorios (bin2hex(random_bytes(16))). Una clave predecible invita a la enumeración en cuanto el bucket queda expuesto, aunque sea en parte.

Este patrón cambia una transferencia síncrona por una subida más una pequeña respuesta JSON. El worker de aplicación queda retenido solo para la construcción del PDF y la subida al almacenamiento, no para la descarga completa del cliente. La descarga en sí transcurre entre el cliente y el almacenamiento (o su edge), así que no consume worker de app en absoluto.

La construcción sigue siendo síncrona y sigue dominando para documentos grandes o de varias páginas: getPdfData() materializa el PDF entero en memoria antes de que puedas subirlo. Para documentos pesados, mueve la generación y la subida a un job en cola y entrega la URL firmada fuera de banda (por ejemplo, notificando al cliente cuando el objeto esté listo). Consulta Genera un PDF en un job en cola.

  • Mantén el bucket privado; deja que la firma conceda el acceso. Nunca hagas el objeto legible públicamente para «simplificar» la entrega. El sentido entero es que el acceso fluya solo a través de una firma de corta vida.
  • Caducidad corta y acotada. Firma para la ventana más pequeña que encaje en tu flujo, y acota cada URL a un solo objeto. Un enlace filtrado caduca entonces por sí solo y no expone nada más.
  • Secretos desde el entorno. Las credenciales de S3/GCS y el APP_SECRET de Symfony que respalda UriSigner provienen de variables de entorno o de un gestor de secretos, nunca de configuración confirmada. Rotar el secreto de firma invalida de inmediato cada ruta firmada pendiente.
  • Verifica antes de servir en rutas firmadas de app. Cuando la descarga cruza tu app (middleware signed de Laravel, UriSigner::checkRequest() de Symfony), verifica la firma antes de cualquier acceso a almacenamiento o autorización. Rechaza un enlace manipulado o caducado con un estado definido.
  • Nunca registres la URL firmada completa. La firma es una credencial al portador. Registra la clave del objeto y un identificador de correlación, no la URL firmada, y registra la clase de la excepción en caso de fallo, nunca el mensaje ni una traza de pila.
  • Sin catch vacío. Cada ejemplo registra la clase de fallo y devuelve una respuesta de error definida.

Esta guía no hace ninguna afirmación normativa de estándares. La única LLAMADA al motor de NextPDF que requiere este patrón de entrega es NextPDF\Core\Document::getPdfData(), el método público verificado que devuelve el binario PDF en bruto; el documento en sí se construye como ya construya documentos tu app (p. ej., el DocumentFactoryInterface inyectado / el PdfFactory de Symfony). Las primitivas de firma son API documentadas de framework y de nube —Laravel Storage::temporaryUrl() y URL::temporarySignedRoute(), Symfony UriSigner, y las operaciones del SDK de URL prefirmada de S3/GCS— y sus firmas exactas, drivers admitidos y ventanas máximas de caducidad las gobiernan esos proyectos upstream. Consulta su documentación para conocer el contrato autoritativo de cada plataforma.