Ir al contenido

Transmitir un PDF grande como respuesta HTTP generada

Se genera un PDF grande dentro de un controlador y se quieren devolver los bytes sin retener una segunda copia completa en el búfer de la respuesta. Cada integración de framework incluye una variante transmitida de su fábrica PdfResponse: streamInline() y streamDownload(). Cada una devuelve un StreamedResponse del framework cuya devolución de llamada escribe el cuerpo del PDF al cliente en fragmentos fijos de 64 KB.

Conviene leer con cuidado el modelo de memoria antes de elegir este camino. El motor construye primero el documento completo en memoria. La devolución de llamada transmitida invoca getPdfData(), que materializa todo el PDF como una sola cadena y luego recorre esa cadena en porciones de 64 KB. El pico que se ahorra es la segunda copia que un Illuminate\Http\Response o Symfony\Component\HttpFoundation\Response con búfer retendría mientras el framework mide Content-Length. La variante transmitida no mide la longitud, por lo que omite Content-Length. Nunca retiene el cuerpo de la respuesta y la cadena del documento al mismo tiempo. No es transmisión incremental real: NextPDF no tiene una superficie de escritura incremental, así que el documento se materializa por completo antes de que el primer byte llegue al socket.

Requisitos previos, declarados desde el inicio para evitar sorpresas a mitad de la tarea:

  • El core de NextPDF está instalado y una integración de framework, nextpdf/laravel o nextpdf/symfony, está instalada y descubierta.
  • Ya se sabe cómo enrutar una solicitud hacia un controlador en el framework.
  • Ya se leyó Devolver un PDF generado desde un controlador, que cubre las fábricas con búfer inline() y download() sobre las que se construye esta recipe.

Esta guía práctica se centra en el patrón StreamedResponse que comparten Laravel y Symfony. CodeIgniter 4 incluye los mismos nombres de método streamInline() / streamDownload(), pero envuelve los bytes en un CodeIgniter\HTTP\DownloadResponse en lugar de un StreamedResponse impulsado por una devolución de llamada. La sección de casos límite deja constancia de esa diferencia.

Instala la integración que coincida con el framework. Ejecuta uno de los siguientes comandos.

Ventana de terminal
composer require nextpdf/laravel
Ventana de terminal
composer require nextpdf/symfony

En Laravel, publica la configuración después de instalar.

Ventana de terminal
php artisan vendor:publish --tag=nextpdf-config

Symfony registra el bundle automáticamente a través de Flex. Confirma el descubrimiento en la página de instalación del framework antes de continuar.

Una fábrica de respuestas con búfer, PdfResponse::download() o PdfResponse::inline(), invoca getPdfData(), almacena la cadena devuelta en un objeto Response y establece Content-Length a partir de strlen(). Entonces, el framework retiene esa cadena durante toda la vida de la respuesta. En un documento grande, eso significa que la cadena del documento y la cadena del cuerpo de la respuesta conviven en memoria.

La fábrica transmitida adopta una forma distinta. PdfResponse::streamDownload() y PdfResponse::streamInline() devuelven un StreamedResponse construido con una devolución de llamada. El framework invoca esa devolución de llamada solo cuando está listo para enviar el cuerpo. Dentro de la devolución de llamada, la integración invoca getPdfData() una vez, divide la cadena devuelta en fragmentos de 64 KB y hace echo de cada fragmento seguido de un flush(). No se retiene ninguna segunda copia persistente del cuerpo ni se emite la cabecera Content-Length.

Dos hechos dan forma a cada decisión en esta página:

  • La construcción es ansiosa, la transferencia es fragmentada. getPdfData() en NextPDF\Core\Document invoca al escritor y devuelve todo el PDF como una sola cadena. La fragmentación de 64 KB solo gobierna cómo salen del proceso los bytes ya construidos. La memoria pico está acotada por el tamaño de un documento terminado, no por una pequeña ventana de transmisión.
  • Sin Content-Length. La variante transmitida no puede conocer la longitud del cuerpo sin construirlo dentro de la devolución de llamada, así que omite la cabecera. Una barra de progreso del cliente, una solicitud Range o un proxy sensible a la longitud no verán ningún tamaño. Elige download() / inline() con búfer cuando una longitud conocida importe más que ahorrar la copia de respuesta.

Obtén el documento a través de la ruta de resolución idiomática del framework:

  • Laravel: resuelve NextPDF\Contracts\DocumentFactoryInterface desde el contenedor e invoca create(). Devuelve un NextPDF\Core\Document nuevo, el tipo concreto que aceptan las fábricas transmitidas.
  • Symfony: inyecta NextPDF\Symfony\Service\PdfFactory e invoca create(). Devuelve un NextPDF\Core\Document nuevo con los valores predeterminados configurados ya aplicados.
AsuntoLaravelSymfony
Documento nuevoapp(DocumentFactoryInterface::class)->create()PdfFactory::create()
Inline transmitidoPdfResponse::streamInline($doc, $name)PdfResponse::streamInline($doc, $name)
Descarga transmitidaPdfResponse::streamDownload($doc, $name)PdfResponse::streamDownload($doc, $name)
Tipo devueltoSymfony\Component\HttpFoundation\StreamedResponseSymfony\Component\HttpFoundation\StreamedResponse
Llamada de construcción dentro de la devolución de llamadaNextPDF\Core\Document::getPdfData()NextPDF\Core\Document::getPdfData()
Tamaño de fragmento64 KB (str_split determinista)64 KB (bucle substr determinista)

El PdfResponse de Laravel vive en NextPDF\Laravel\Http\PdfResponse; el de Symfony, en NextPDF\Symfony\Http\PdfResponse. Sus fábricas transmitidas devuelven ambas el mismo tipo Symfony\Component\HttpFoundation\StreamedResponse. Ambas aplican el mismo conjunto fijo de cabeceras de endurecimiento de respuesta del Open Web Application Security Project (OWASP) (X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Content-Security-Policy: default-src 'none', X-Robots-Tag: noindex, nofollow, Referrer-Policy: no-referrer) y ambas sanean el nombre de archivo de la descarga. No es necesario agregar esas cabeceras manualmente.

Ambas fábricas invocan la misma superficie core subyacente, NextPDF\Core\Document::getPdfData(): string, que construye y devuelve todo el binario del PDF. Su homólogo save(string $path): void escribe los mismos bytes en disco mediante un escritor atómico. Esta recipe usa getPdfData() porque el destino es un socket HTTP, no un archivo.

Esta es la acción mínima de descarga transmitida en cada framework. Las llamadas al documento usan la misma superficie core; solo difiere el andamiaje del controlador. La fábrica transmitida entrega al framework una devolución de llamada, por lo que la acción retorna de inmediato. El cuerpo se construye y se vacía cuando el framework envía la respuesta.

Laravel: app/Http/Controllers/ReportController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use NextPDF\Contracts\DocumentFactoryInterface;
use NextPDF\Laravel\Http\PdfResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class ReportController extends Controller
{
public function annualReport(): StreamedResponse
{
$document = app(DocumentFactoryInterface::class)->create();
$document->addPage();
$document->cell(0, 10, 'Annual report', newLine: true);
return PdfResponse::streamDownload($document, 'annual-report.pdf');
}
}
Symfony: src/Controller/ReportController.php
<?php
declare(strict_types=1);
namespace App\Controller;
use NextPDF\Symfony\Http\PdfResponse;
use NextPDF\Symfony\Service\PdfFactory;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
final class ReportController
{
#[Route('/report', name: 'report_pdf')]
public function annualReport(PdfFactory $pdf): StreamedResponse
{
$document = $pdf->create();
$document->addPage();
$document->cell(0, 10, 'Annual report', newLine: true);
return PdfResponse::streamDownload($document, 'annual-report.pdf');
}
}

Para previsualizar en una pestaña del navegador en lugar de forzar una descarga, invoca streamInline(...) en lugar de streamDownload(...). El Content-Disposition pasa a ser inline y todas las demás cabeceras permanecen iguales.

Una acción de producción inyecta sus dependencias, valida la entrada de la ruta, captura la excepción más específica que la construcción puede lanzar, registra la clase de fallo sin filtrar una traza y devuelve un error HTTP definido. El ejemplo siguiente usa inyección por constructor de Laravel. El equivalente en Symfony sigue la misma forma, con PdfFactory inyectado por acción.

getPdfData() se ejecuta dentro de la devolución de llamada transmitida, así que cualquier excepción que lance aparece después de que el framework haya empezado a enviar las cabeceras. Para que el manejo de errores siga siendo significativo, construye el documento (el paso que puede fallar) antes de devolver la respuesta y captura allí el fallo de construcción. Solo la transferencia fragmentada de los bytes ya construidos ocurre después dentro de la devolución de llamada.

Laravel: app/Http/Controllers/StatementController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Response;
use NextPDF\Contracts\DocumentFactoryInterface;
use NextPDF\Core\Document;
use NextPDF\Exception\NextPdfException;
use NextPDF\Laravel\Http\PdfResponse;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class StatementController extends Controller
{
private const int MAX_STATEMENT_ID = 9_999_999;
public function __construct(
private readonly DocumentFactoryInterface $documents,
private readonly LoggerInterface $logger,
) {}
public function show(int $statementId): StreamedResponse|Response
{
// Validate input at the boundary before any build work runs.
if ($statementId < 1 || $statementId > self::MAX_STATEMENT_ID) {
return new Response('Invalid statement identifier.', 422);
}
try {
// Build the whole document up front. getPdfData(), invoked inside
// the streamed callback, materializes the full PDF in memory, so
// do the failure-prone build here, where the catch can still set a
// clean HTTP status before any byte is sent.
$document = $this->buildStatement($statementId);
$document->getPdfData();
} catch (NextPdfException $exception) {
// Log the exception class, never the message or a stack trace, so
// internal detail does not leak into the log sink.
$this->logger->error('Statement PDF build failed', [
'statement_id' => $statementId,
'exception' => $exception::class,
]);
return new Response('Could not generate the statement PDF.', 500);
}
// The build succeeded. The streamed factory rebuilds the bytes inside
// its callback and flushes them to the client in 64 KB chunks.
return PdfResponse::streamDownload(
$document,
"statement-{$statementId}.pdf",
);
}
private function buildStatement(int $statementId): Document
{
$document = $this->documents->create();
$document->addPage();
$document->cell(0, 10, "Statement #{$statementId}", newLine: true);
return $document;
}
}

Captura NextPDF\Exception\NextPdfException, la base abstracta que extienden todas las excepciones de NextPDF, cuando se necesite un único manejador para cualquier fallo de construcción. Para reaccionar ante causas específicas, captura primero los subtipos concretos que getPdfData() puede lanzar: NextPDF\Exception\PageLayoutException cuando el contenido no cabe en la geometría de la página, NextPDF\Exception\CompressionException cuando falla la compresión del flujo, y NextPDF\Exception\InvalidConfigException para una configuración de salida inválida. Nunca escribas un bloque catch vacío. Cada rama aquí registra la clase de fallo y devuelve un estado definido.

Resolver un documento nuevo por acción mantiene la fábrica intercambiable en las pruebas. No reutilices una sola instancia de controlador para dos documentos no relacionados dentro de un único proceso de worker de larga duración, porque se arrastraría estado de contenido obsoleto.

  • El documento se construye dos veces en el patrón de validar-y-luego-transmitir. El ejemplo de producción invoca getPdfData() una vez para validar la construcción y luego la fábrica lo invoca de nuevo dentro de la devolución de llamada. Ese es el costo de mover el punto de fallo antes de las cabeceras. Cuando una doble construcción resulte demasiado costosa para un documento dado, omite la sonda de preconstrucción y acepta que un fallo de construcción dentro de la devolución de llamada truncará una respuesta ya iniciada.
  • Sin Content-Length. La variante transmitida omite la cabecera. Las barras de progreso de descarga y las solicitudes Range no funcionarán. Usa download() / inline() con búfer cuando se requiere una longitud conocida.
  • Un proxy con búfer anula el beneficio. Un proxy inverso o un búfer de salida de PHP que captura todo el cuerpo antes de reenviarlo vuelve a retener el PDF completo y elimina la copia ahorrada. Configura el proxy para transmitir las respuestas application/pdf, o usa una respuesta con búfer en esa ruta.
  • CodeIgniter 4 no es de transmisión por devolución de llamada. La integración de CodeIgniter incluye los mismos nombres de método streamInline() / streamDownload(), pero devuelven un CodeIgniter\HTTP\DownloadResponse que retiene el cuerpo completo, no un StreamedResponse impulsado por una devolución de llamada. El patrón StreamedResponse de esta página aplica solo a Laravel y Symfony.
  • No escribas en el cuerpo después de retornar. La devolución de llamada transmitida es dueña de la salida. No hagas echo ni escribas manualmente en el cuerpo de la respuesta después de devolver el StreamedResponse al framework.
  • Los documentos firmados fallan rápido. Invocar getPdfData() en un documento configurado para una firma PAdES de alto nivel lanza NextPDF\Exception\NotImplementedException en lugar de emitir un archivo sin firmar. Transmite la salida firmada a través de la ruta de firma documentada, no a través de esta recipe.

La transmisión acota la copia de la respuesta, no la construcción del documento. La memoria pico es aproximadamente el tamaño de un PDF terminado, porque getPdfData() materializa todo el documento antes de que se envíe el primer fragmento. En un documento realmente grande o de varias páginas, la construcción en sí, no la transferencia, domina el presupuesto de la solicitud. Mueve la generación fuera del hilo de la solicitud con un trabajo en cola. Consulta Generar un PDF en un trabajo en cola.

El tamaño de fragmento de 64 KB es fijo y determinista en ambas integraciones. Solo gobierna la granularidad de la transferencia y no cambia el total de bytes enviados ni la memoria pico. Elige la variante transmitida cuando la copia de respuesta ahorrada sea la restricción y no se requiera una barra de progreso. Elige la variante con búfer para respuestas pequeñas y sensibles a la latencia que se beneficien de un Content-Length conocido.

  • Valida la entrada antes de construir. La acción de producción rechaza un identificador fuera de rango con un 422 antes de que se ejecute cualquier trabajo de construcción. Nunca interpoles entrada no validada en la construcción ni en el nombre de archivo.
  • El saneamiento del nombre de archivo se aplica por ti. Ambas fábricas transmitidas sanean el nombre de archivo y añaden el conjunto de cabeceras de endurecimiento de respuesta de OWASP. Pasa un valor bajo control propio y deja que la fábrica lo sanee como segunda capa. No codifiques el nombre de archivo manualmente.
  • Acota la memoria concurrente. Como todo el PDF se materializa en memoria por solicitud, un tráfico concurrente elevado multiplica la memoria pico. Impón límites de tamaño y de tasa sobre las entradas que impulsan una construcción para mitigar la denegación de servicio por agotamiento de memoria.
  • Registra la clase de fallo, no el mensaje. El bloque catch registra $exception::class y un identificador de correlación, nunca el mensaje de la excepción ni una traza de pila. Una traza sin procesar en un colector de registros es una fuga de información.
  • Sin catch vacío. Cada rama catch en esta página registra y devuelve una respuesta de error definida.

Esta guía no hace ninguna afirmación normativa de estándares. Cada clase, método y cabecera mostrados son la superficie pública verificada de la integración nombrada: NextPDF\Core\Document::getPdfData(), las fábricas transmitidas NextPDF\Laravel\Http\PdfResponse y NextPDF\Symfony\Http\PdfResponse, y el tipo de retorno Symfony\Component\HttpFoundation\StreamedResponse. La semántica de las cabeceras de endurecimiento de respuesta de OWASP que aplican las fábricas está documentada, con sus citas, en la página de seguridad y operaciones de cada integración, enlazada en Consulta también. Esta página del cookbook reformula el uso y delega las citas normativas a esas páginas.