Entregue um PDF gerado por meio de uma URL assinada e expirável
Visão geral
Seção intitulada “Visão geral”Você gera um arquivo Portable Document Format (PDF) e precisa entregá-lo a um cliente. O caminho mais simples faz streaming dos bytes direto por um controller, mas isso ocupa um worker de aplicação durante todo o download, roteia o tráfego pelos seus servidores e expõe o arquivo a qualquer um que consiga alcançar a rota. O padrão de entrega desta página faz o oposto: gera o PDF, armazena os bytes no armazenamento de objetos e retorna um Localizador Uniforme de Recursos (URL) assinado e de curta duração que o cliente busca diretamente do armazenamento. Sua aplicação devolve um pequeno payload JavaScript Object Notation (JSON) com uma URL; o armazenamento serve os bytes.
O lado do NextPDF é uma única chamada: getPdfData() no documento retorna o
binário PDF bruto como uma string. Tudo depois disso — colocar o objeto e cunhar
um link assinado com tempo limitado — é trabalho do seu framework ou do seu
provedor de nuvem. As primitivas de assinatura são APIs reais e documentadas:
Storage::temporaryUrl() e URL::temporarySignedRoute() do Laravel, o UriSigner
do Symfony, e as operações de URL pré-assinada do Amazon Simple Storage Service
(S3) ou do Google Cloud Storage (GCS) em seus kits de desenvolvimento de software
(SDKs). O NextPDF não define nenhum helper de URL próprio; não procure por um.
Verifique estas peças primeiro:
- O NextPDF core está instalado e você consegue construir um documento.
- Você tem armazenamento de objetos que o framework consegue assinar: um bucket S3 ou compatível com S3, um bucket GCS, ou um disco Laravel cujo driver suporta URLs temporárias.
- As credenciais ficam em variáveis de ambiente ou em um gerenciador de segredos, nunca em configuração commitada.
Este é um how-to. Ele assume que você já sabe como rotear uma requisição para um controller. Para retornar bytes diretamente em vez disso, consulte Retorne um PDF gerado a partir de um controller.
Visão conceitual
Seção intitulada “Visão conceitual”O padrão tem três passos, e apenas o primeiro toca o NextPDF:
- Gerar. Construa o documento e chame
getPdfData()para obter os bytes. - Armazenar. Escreva esses bytes em uma chave de armazenamento de objetos (
reports/2026/r-42.pdf). - Assinar. Peça ao framework ou ao SDK de nuvem uma URL assinada para essa chave, com uma expiração, e retorne a URL ao cliente.
Por que armazenar e assinar em vez de fazer proxy dos bytes:
- Descarregar largura de banda. O armazenamento de objetos (ou sua edge de rede de entrega de conteúdo) serve o download. Seu worker de aplicação retorna algumas centenas de bytes de JSON e fica livre imediatamente, em vez de ficar retido pela duração de uma transferência de vários megabytes.
- Limitar o acesso. Uma URL assinada concede acesso a um objeto por uma janela limitada. O bucket em si permanece privado. Não há rota pública para fazer brute force e nenhuma concessão ampla de leitura do bucket.
- Expiração. A assinatura embute um timestamp de expiração. Depois que ele passa, o link morre. Uma URL vazada para de funcionar sozinha, o que limita o raio de explosão de um compartilhamento acidental.
Há dois modelos distintos de assinatura, e eles diferem em o que é assinado:
- URLs pré-assinadas de armazenamento de objetos (S3, GCS, ou o
temporaryUrl()do Laravel sobre um disco S3/GCS) apontam diretamente para o objeto de armazenamento. O download nunca chega à sua aplicação. - Rotas assinadas da aplicação (
URL::temporarySignedRoute()do Laravel,UriSignerdo Symfony) apontam para sua própria rota. A requisição ainda atinge sua aplicação, que verifica a assinatura, e então faz streaming ou redireciona para o objeto. Use-as quando você precisa executar autorização, logging ou contabilização em cada download, ou quando seu armazenamento não pode pré-assinar.
Superfície da API
Seção intitulada “Superfície da API”| Aspecto | NextPDF | Laravel | Symfony |
|---|---|---|---|
| Obter os bytes do PDF | NextPDF\Core\Document::getPdfData(): string | same | same |
| Armazenar os bytes | — | Storage::disk($d)->put($key, $bytes) | Filesystem::dumpFile($path, $bytes) or Flysystem write() |
| URL pré-assinada de armazenamento | — | Storage::disk($d)->temporaryUrl($key, $expiresAt) | AWS/GCS SDK presigner (below) |
| Rota de app assinada | — | URL::temporarySignedRoute($name, $expiresAt, $params) | UriSigner::sign($url) |
| Verificar uma rota de app assinada | — | signed route middleware / $request->hasValidSignature() | UriSigner::check() / checkRequest() |
A única CHAMADA do mecanismo NextPDF que este padrão de entrega requer é
getPdfData(); o documento em si é construído da forma como sua aplicação já
constrói documentos (por exemplo, o DocumentFactoryInterface injetado / o
PdfFactory do Symfony). getPdfData() é declarado no trait HasOutput em
NextPDF\Core\Document. Ele chama o escritor uma vez e retorna o PDF inteiro como
uma string. Sua irmã save(string $path): void escreve os mesmos bytes no disco
por meio de um escritor atômico; use-a apenas quando seu armazenamento for um
caminho de sistema de arquivos local real. Para armazenamento de objetos, prefira
getPdfData() e deixe o SDK de armazenamento cuidar da transferência.
O documento é construído quando você chama
getPdfData()(ousave()), e o build não é idempotente. Chame-o uma vez por documento, capture a string e reutilize essa string tanto para o upload quanto para qualquer tamanho ou checksum que você calcular.
Exemplo de código — URL temporária do Laravel
Seção intitulada “Exemplo de código — URL temporária do Laravel”A abstração de sistema de arquivos do Laravel assina por você. Em um disco S3 (ou
compatível com S3), Storage::temporaryUrl() retorna uma URL pré-assinada direto
para o objeto. O cliente baixa do armazenamento; sua action retorna apenas JSON.
<?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); } }}O disco deve ser um cujo driver suporte URLs temporárias — o driver s3
empacotado suporta. Chamar temporaryUrl() no driver local lança uma exceção a
menos que você registre um gerador para ele, porque um disco local não tem nada
para pré-assinar.
Quando você prefere manter o download na sua própria rota — para executar
autorização por requisição ou registrar cada acesso — assine uma rota em vez disso
com URL::temporarySignedRoute(). O middleware signed da rota rejeita um link
adulterado ou expirado antes que sua action seja executada.
<?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');Exemplo de código — UriSigner do Symfony
Seção intitulada “Exemplo de código — UriSigner do Symfony”O Symfony não tem uma facade de armazenamento no estilo do Laravel, então você
assina sua própria rota com o Symfony\Component\HttpFoundation\UriSigner do
framework, e então faz essa rota redirecionar para uma URL de armazenamento
pré-assinada (ou fazer streaming do objeto). UriSigner::sign() anexa um hash com
chave; checkRequest() rejeita um link adulterado. Para manter o exemplo portável
entre versões do Symfony, embuta seu próprio parâmetro de query expires (um
timestamp Unix de alguns minutos à frente) antes de assinar, e então valide
esse parâmetro você mesmo na rota download depois que a assinatura for conferida.
Isso funciona em toda versão do Symfony, porque UriSigner::sign(string $uri)
recebe apenas a URL.
<?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'), )]); }}O UriSigner é construído com um segredo (o Symfony o injeta automaticamente a
partir do parâmetro %kernel.secret% / APP_SECRET). O exemplo acima é o caminho
portável: UriSigner::sign(string $uri) assina apenas a URL e existe em toda
versão do Symfony, então a expiração viaja como seu próprio parâmetro de query
expires. A assinatura cobre esse parâmetro, então ele não pode ser adulterado —
e depois que checkRequest() passa, a rota download o aplica comparando o
timestamp com a hora atual e retornando 410 Gone quando ele estiver no passado.
Em versões do Symfony cujo
UriSigner::sign()aceita um argumento de expiraçãoDateTimeInterface, você pode passar a expiração diretamente —$signer->sign($url, new \DateTimeImmutable('+10 minutes'))— e deixarcheckRequest()rejeitar os links expirados por você, dispensando o parâmetro manualexpirese sua verificação. Confirme a assinatura deUriSigner::sign()no seu Symfony instalado antes de confiar nela; o padrão portável acima funciona de qualquer forma.
Exemplo de código — URL pré-assinada do SDK de nuvem
Seção intitulada “Exemplo de código — URL pré-assinada do SDK de nuvem”Se você assina com um SDK de nuvem diretamente em vez de por meio de um disco de
framework, o formato é o mesmo: coloque o objeto, e então peça ao SDK para
pré-assinar um GET para ele. Isto é S3 puro (o fluxo do GCS o espelha: obtenha o
objeto com $bucket->object($key) e chame $object->signedUrl($expiresAt, [...])).
<?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 o GCS, construa os bytes da mesma forma com getPdfData(), faça upload do
objeto com o cliente do Cloud Storage, e então obtenha o objeto de armazenamento
com $bucket->object($key) e chame $object->signedUrl($expiresAt, [...]) com uma
expiração Carbon/DateTime para cunhar o link equivalente. A expiração da URL
assinada em ambos os provedores é limitada pelo tipo de credencial; consulte a
documentação do provedor para o tempo de vida máximo que suas credenciais permitem.
Casos extremos e armadilhas
Seção intitulada “Casos extremos e armadilhas”- Construa o documento exatamente uma vez.
getPdfData()dispara o build, e o build não é idempotente. Chame-o uma vez, segure a string e reutilize-a tanto para o upload quanto para qualquerContent-Length, checksum ouETagque você calcular. Não o chame de novo para “reler” os bytes. temporaryUrl()precisa de um driver capaz de pré-assinar. O drivers3do Laravel pré-assina; o driverlocallança uma exceção emtemporaryUrl()a menos que você registre um gerador personalizado comStorage::disk('local')->buildTemporaryUrlsUsing(...). Escolha um disco que consiga assinar, ou assine uma rota de app em vez disso.- Defina o content type do objeto. Armazene com
Content-Type: application/pdf(a opção de uploadContentType, ou metadados do disco) para que o navegador abra o link pré-assinado como um PDF em vez de baixar umoctet-stream. - Uma expiração curta pode não sobreviver a um cliente lento. Se o usuário clicar no link bem depois de você cunhá-lo, uma janela de 60 segundos pode já estar morta. Dimensione a expiração para a lacuna realista entre cunhar e o primeiro byte — minutos, não segundos — e cunhe novamente sob demanda em vez de estendê-la para horas.
- Uma URL assinada é acesso bearer. Qualquer um que segure a URL antes que ela expire pode baixar o objeto. Mantenha as expirações curtas, prefira escopo de um objeto e nunca registre a URL assinada completa — a assinatura é efetivamente um token.
- Não embuta entrada do usuário na chave do objeto sem sanitizar. Construa as
chaves a partir de valores que você controla mais bytes aleatórios
(
bin2hex(random_bytes(16))). Uma chave previsível convida à enumeração assim que o bucket é até parcialmente exposto.
Desempenho
Seção intitulada “Desempenho”Este padrão troca uma transferência síncrona por um upload mais uma pequena resposta JSON. O worker de aplicação fica retido apenas pelo build do PDF e pelo upload para o armazenamento, não pelo download completo do cliente. O download em si ocorre entre o cliente e o armazenamento (ou sua edge), então ele não consome um worker de app de forma alguma.
O build ainda é síncrono e ainda domina para documentos grandes ou de várias
páginas — getPdfData() materializa o PDF inteiro em memória antes que você possa
fazer o upload. Para documentos pesados, mova a geração e o upload para um job em
fila e entregue a URL assinada fora de banda (por exemplo, notificando o cliente
quando o objeto estiver pronto). Consulte
Gere um PDF em um job em fila.
Notas de segurança
Seção intitulada “Notas de segurança”- Mantenha o bucket privado; deixe a assinatura conceder acesso. Nunca torne o objeto publicamente legível para “simplificar” a entrega. O ponto central é que o acesso flui apenas por uma assinatura de curta duração.
- Expiração curta e com escopo. Assine para a menor janela que sirva ao seu fluxo, e dê escopo a cada URL para um único objeto. Um link vazado então expira sozinho e não expõe mais nada.
- Segredos do ambiente. As credenciais S3/GCS e o
APP_SECRETdo Symfony que embasa oUriSignervêm de variáveis de ambiente ou de um gerenciador de segredos, nunca de configuração commitada. Rotacionar o segredo de assinatura invalida imediatamente toda rota assinada pendente. - Verifique antes de servir em rotas assinadas de app. Quando o download cruza
sua aplicação (middleware
signeddo Laravel,UriSigner::checkRequest()do Symfony), verifique a assinatura antes de qualquer acesso ao armazenamento ou autorização. Rejeite um link adulterado ou expirado com um status definido. - Nunca registre a URL assinada completa. A assinatura é uma credencial bearer. Registre a chave do objeto e um identificador de correlação, não a URL assinada, e registre a classe da exceção em caso de falha — nunca a mensagem ou um stack trace.
- Sem
catchvazio. Todo exemplo registra a classe da falha e retorna uma resposta de erro definida.
Conformidade
Seção intitulada “Conformidade”Este guia não faz nenhuma declaração normativa de padrões. A única CHAMADA do
mecanismo NextPDF que este padrão de entrega requer é
NextPDF\Core\Document::getPdfData(), o método público verificado que retorna o
binário PDF bruto; o documento em si é construído da forma como sua aplicação já
constrói documentos (por exemplo, o DocumentFactoryInterface injetado / o
PdfFactory do Symfony). As primitivas de assinatura são APIs documentadas de
framework e nuvem — Storage::temporaryUrl() e URL::temporarySignedRoute() do
Laravel, o UriSigner do Symfony, e as operações de SDK de URL pré-assinada do
S3/GCS — e suas assinaturas exatas, drivers suportados e janelas máximas de
expiração são governadas por esses projetos upstream. Consulte a documentação deles
para o contrato autoritativo em cada plataforma.
Veja também
Seção intitulada “Veja também”- Retorne um PDF gerado a partir de um controller — faça streaming dos bytes diretamente quando você não quer armazenamento de objetos no fluxo.
- Faça streaming de um PDF grande gerado como resposta HTTP — o modelo de memória buffered-vs-streamed por trás de
getPdfData(). - Renderize na edge com Cloudflare — a variante deste padrão específica para R2, com URL assinada e renderização na edge.
- Gere um PDF em um job em fila — mova o build e o upload para fora da thread de requisição.