Production usage — fallback, telemetry, archival, protection
At a glance
Section titled “At a glance”This page covers four production concerns beyond a basic render: local fallback, edge telemetry, Cloudflare R2 archival, and the inbound application programming interface (API) protection layer. Each section maps to verified class behavior.
Local fallback
Section titled “Local fallback”When the Worker is unreachable and fallbackToLocal is true, the
bridge delegates rendering to a local renderer. Provide that renderer
through LocalRendererFactoryInterface. The bridge creates it lazily,
so the factory’s create() runs only on the fallback path.
<?php
declare(strict_types=1);
use NextPDF\Cloudflare\Contract\LocalRendererFactoryInterface;use NextPDF\Cloudflare\Contract\LocalRendererInterface;
final class ArtisanLocalRendererFactory implements LocalRendererFactoryInterface{ public function __construct( private readonly \NextPDF\Artisan\ChromeHtmlRenderer $chrome, ) {}
public function create(): LocalRendererInterface { return new readonly class($this->chrome) implements LocalRendererInterface { public function __construct( private \NextPDF\Artisan\ChromeHtmlRenderer $chrome, ) {}
/** @param array<string, mixed> $options */ public function render(string $html, array $options = []): string { // Delegate to the local Chrome renderer; return raw PDF bytes. return $this->chrome->renderToString($html, $options); } }; }}Wire the factory into the renderer:
use NextPDF\Cloudflare\CloudflareHtmlRenderer;
$renderer = new CloudflareHtmlRenderer( config: $config, httpClient: $httpClient, requestFactory: $httpFactory, streamFactory: $httpFactory, logger: $logger, localRendererFactory: new ArtisanLocalRendererFactory($chrome), responseFactory: $httpFactory,);When fallback runs, the result’s renderLocation is the literal string
local, and heightPt is 0.0. The local path does not report an edge
location or measured height. The bridge passes the requested width to
the local renderer through the widthPt option key.
Fallback decision logic
Section titled “Fallback decision logic”Read directly from CloudflareHtmlRenderer:
| Situation | Outcome |
|---|---|
Config incomplete, fallbackToLocal: false | CloudflareNotAvailableException |
Config incomplete, fallbackToLocal: true, factory wired | Local render |
| Worker throws a transport error, fallback enabled, factory wired | Local render, logged at warning then info |
| Worker throws, fallback enabled, Artisan installed, no factory | CloudflareNotAvailableException naming the missing factory |
| Worker throws, fallback enabled, Artisan not installed | CloudflareNotAvailableException naming the missing package |
| Worker returns a Hypertext Transfer Protocol (HTTP) error / malformed body | CloudflareRenderException, never falls back |
The last row is critical. A Worker that returns an error is a render failure, not a reachability failure. The bridge rethrows it so your code can distinguish a broken render from an unreachable edge.
Edge telemetry
Section titled “Edge telemetry”Every successful binary-path render includes telemetry from response headers:
$result = $renderer->render($html);
$logger->info('edge render', [ 'edge' => $result->renderLocation, // e.g. 'TPE', 'NRT' 'render_time_ms' => $result->renderTimeMs, 'content_px' => $result->contentHeightPx, 'pdf_bytes' => $result->size(),]);The renderer reads renderLocation from the CF-Ray response header
and takes the segment after the final hyphen. For CF-Ray: 8abc123def456-TPE the location is TPE. When the header is absent, the
location is an empty string. On the JavaScript Object Notation (JSON)
response path, the value comes from the JSON renderLocation field
instead. Treat these values as observability signals from the Worker,
not as platform guarantees.
R2 archival
Section titled “R2 archival”R2ArchiveManager uploads Portable Document Format (PDF) bytes to
Cloudflare R2 through the Amazon Simple Storage Service (S3)-compatible
API and signs requests with Amazon Web Services (AWS) Signature V4.
use NextPDF\Cloudflare\R2ArchiveConfig;use NextPDF\Cloudflare\R2ArchiveManager;
$r2 = new R2ArchiveManager( config: new R2ArchiveConfig( bucketName: 'pdf-archive', accountId: getenv('CF_ACCOUNT_ID') ?: '', accessKeyId: getenv('R2_ACCESS_KEY_ID') ?: '', secretAccessKey: getenv('R2_SECRET_ACCESS_KEY') ?: '', pathPrefix: 'invoices/', ), httpClient: $httpClient, requestFactory: $httpFactory, streamFactory: $httpFactory,);
$upload = $r2->upload($result->pdfData, 'invoice-2026-0042.pdf', [ 'tenant' => 'acme',]);
if (!$upload->success) { $logger->error('r2 upload failed', ['error' => $upload->error]);}Behavior verified from R2ArchiveManager and R2ObjectKey:
- The object key is date-partitioned as:
<pathPrefix><Y>/<m>/<d>/<sanitized-filename>, for exampleinvoices/2026/05/18/invoice-2026-0042.pdf. - The filename is sanitized:
basename()removes path traversal, then null bytes and control characters (\x00–\x1f,\x7f) are stripped. An empty result becomesdocument.pdf. - Custom metadata is sent as
x-amz-meta-<lowercased-key>headers, included in the V4 signed-header set. - Files larger than
maxFileSizeBytes(default104857600) are rejected before any request and return anR2UploadResultwithsuccess: false. R2UploadResult::isValid()requiressuccess, a non-emptykey, and a non-emptyetag.
Pre-signed download URLs
Section titled “Pre-signed download URLs”$url = $r2->generateSignedUrl('invoices/2026/05/18/invoice-2026-0042.pdf', 900);generateSignedUrl() builds an AWS Signature V4 query-signed GET URL
with an X-Amz-Expires value you control (default 3600 seconds). The
canonical request uses the UNSIGNED-PAYLOAD content-hash sentinel. A
query-signed read URL uses this form because the body is not part of the
signed request. This describes the package’s implemented signing
behavior, as read from R2ArchiveManager. Amazon’s service
documentation defines AWS Signature Version 4, not a standards
development organization (SDO) standard, so no normative clause is
pinned here. Object access keys are
#[SensitiveParameter]; keep them out of logs.
Public URLs
Section titled “Public URLs”R2UploadResult::publicUrl($customDomain) returns the bare key when you
do not provide a domain, or https://<domain>/<key> when you do. It
adds a Hypertext Transfer Protocol Secure (HTTPS) scheme when the
supplied domain has none. It does not make a private bucket public; that
remains an R2 bucket configuration concern.
Inbound API protection
Section titled “Inbound API protection”ApiProtection is the layer you apply to render requests that arrive at
a PHP gateway in front of the Worker. It checks in a fixed order: API
key, then payload size, then rate limit.
use NextPDF\Cloudflare\ApiKeyValidator;use NextPDF\Cloudflare\ApiProtection;use NextPDF\Cloudflare\ApiProtectionConfig;
$protection = new ApiProtection( config: new ApiProtectionConfig( maxRequestsPerMinute: 30, maxRequestsPerHour: 500, maxPayloadSizeBytes: 5_000_000, requireApiKey: true, ), keyValidator: new ApiKeyValidator([getenv('GATEWAY_API_KEY') ?: '']),);
$decision = $protection->checkRequest( clientId: $clientIp, payloadSize: strlen($requestBody), apiKey: $request->getHeaderLine('X-Api-Key'),);
if (!$decision->allowed) { http_response_code(429); foreach ($decision->toHeaders() as $name => $value) { header("{$name}: {$value}"); } echo $decision->denialReason; exit;}Verified behavior:
- Order is API key → payload size → rate limit. The first failed check
short-circuits with a specific
denialReason. ApiKeyValidator::validate()useshash_equals()for timing-safe comparison and rejects an empty key.validateHashed()compares against Secure Hash Algorithm 256-bit (SHA-256) hashes for at-rest key storage. Key parameters carry#[SensitiveParameter].- The rate-limit store is in-memory per process. It tracks a
per-minute window (
rateLimitWindowSeconds, default60) and a per-hour window (fixed3600seconds). It does not persist across workers or restarts. To share limits across processes, put a shared store in front of it. ApiProtectionResult::toHeaders()always addsX-Content-Type-Options: nosniffandX-Frame-Options: DENY, and merges the rate-limit headers (X-RateLimit-Remaining,X-RateLimit-Reset, plusRetry-Afterwhen denied).
Render-then-sign
Section titled “Render-then-sign”This bridge does not sign PDFs. To build a production signing pipeline, render at the edge, then sign the returned bytes with the engine:
render()→CloudflareRenderResult::$pdfData.- Hand
$pdfDatatonextpdf/core(or NextPDF Pro for PDF Advanced Electronic Signatures (PAdES) B-B signing). Long-term-validation profiles are an Enterprise capability; this core bridge claims neither capability.
Keep the signing step in your own process so the signing key never crosses the edge boundary.
See also
Section titled “See also”- /integrations/cloudflare/security-and-operations/ — pinning, server-side request forgery (SSRF) defense, secret rotation, and the operational runbook.
- /integrations/cloudflare/troubleshooting/ — failure-mode catalog.
- /integrations/cloudflare/configuration/ — every field and default.