Pular para o conteúdo
getnextpdf.com

Entregue um PDF gerado por meio de uma URL assinada e expirável

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.

O padrão tem três passos, e apenas o primeiro toca o NextPDF:

  1. Gerar. Construa o documento e chame getPdfData() para obter os bytes.
  2. Armazenar. Escreva esses bytes em uma chave de armazenamento de objetos (reports/2026/r-42.pdf).
  3. 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, UriSigner do 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.
AspectoNextPDFLaravelSymfony
Obter os bytes do PDFNextPDF\Core\Document::getPdfData(): stringsamesame
Armazenar os bytesStorage::disk($d)->put($key, $bytes)Filesystem::dumpFile($path, $bytes) or Flysystem write()
URL pré-assinada de armazenamentoStorage::disk($d)->temporaryUrl($key, $expiresAt)AWS/GCS SDK presigner (below)
Rota de app assinadaURL::temporarySignedRoute($name, $expiresAt, $params)UriSigner::sign($url)
Verificar uma rota de app assinadasigned 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() (ou save()), 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.

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.

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);
}
}
}

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.

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');

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.

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'),
)]);
}
}

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ção DateTimeInterface, você pode passar a expiração diretamente — $signer->sign($url, new \DateTimeImmutable('+10 minutes')) — e deixar checkRequest() rejeitar os links expirados por você, dispensando o parâmetro manual expires e sua verificação. Confirme a assinatura de UriSigner::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, [...])).

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 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.

  • 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 qualquer Content-Length, checksum ou ETag que você calcular. Não o chame de novo para “reler” os bytes.
  • temporaryUrl() precisa de um driver capaz de pré-assinar. O driver s3 do Laravel pré-assina; o driver local lança uma exceção em temporaryUrl() a menos que você registre um gerador personalizado com Storage::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 upload ContentType, ou metadados do disco) para que o navegador abra o link pré-assinado como um PDF em vez de baixar um octet-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.

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.

  • 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_SECRET do Symfony que embasa o UriSigner vê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 signed do 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 catch vazio. Todo exemplo registra a classe da falha e retorna uma resposta de erro definida.

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.