Skip to content

Production usage — fallback, telemetry, archival, protection

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.

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.

Read directly from CloudflareHtmlRenderer:

SituationOutcome
Config incomplete, fallbackToLocal: falseCloudflareNotAvailableException
Config incomplete, fallbackToLocal: true, factory wiredLocal render
Worker throws a transport error, fallback enabled, factory wiredLocal render, logged at warning then info
Worker throws, fallback enabled, Artisan installed, no factoryCloudflareNotAvailableException naming the missing factory
Worker throws, fallback enabled, Artisan not installedCloudflareNotAvailableException naming the missing package
Worker returns a Hypertext Transfer Protocol (HTTP) error / malformed bodyCloudflareRenderException, 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.

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.

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 example invoices/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 becomes document.pdf.
  • Custom metadata is sent as x-amz-meta-<lowercased-key> headers, included in the V4 signed-header set.
  • Files larger than maxFileSizeBytes (default 104857600) are rejected before any request and return an R2UploadResult with success: false.
  • R2UploadResult::isValid() requires success, a non-empty key, and a non-empty etag.
$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.

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.

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() uses hash_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, default 60) and a per-hour window (fixed 3600 seconds). It does not persist across workers or restarts. To share limits across processes, put a shared store in front of it.
  • ApiProtectionResult::toHeaders() always adds X-Content-Type-Options: nosniff and X-Frame-Options: DENY, and merges the rate-limit headers (X-RateLimit-Remaining, X-RateLimit-Reset, plus Retry-After when denied).

This bridge does not sign PDFs. To build a production signing pipeline, render at the edge, then sign the returned bytes with the engine:

  1. render()CloudflareRenderResult::$pdfData.
  2. Hand $pdfData to nextpdf/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.

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