Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

Generate the PDF as bytes, store those bytes in durable storage, and return either a public object URL or a time-limited signed URL. Keep the object key (not a temporary signed link) as your durable database reference. The PHP example below uses mPDF for rendering and Amazon S3 through the AWS SDK for PHP for storage and private delivery.

The complete workflow

  1. Render: Build the PDF with a PHP library such as mPDF and capture its binary output.
  2. Store: Write the bytes to a private local directory or upload them to object storage such as Amazon S3.
  3. Authorize: Decide whether anybody with the URL may read the file (public URL) or whether each link should expire (presigned URL).
  4. Return: Send the URL in JSON, an HTTP redirect, or an HTML response. Save the object key or file ID for future requests.

For documents containing personal, financial, or business data, use private storage and issue a fresh signed URL when a user is authorized to download. A signed URL is a bearer credential: anyone who obtains it can use it until it expires or the underlying credentials become invalid.

Generate PDF bytes safely in PHP

Install a renderer

Install mPDF and the AWS SDK with Composer:

composer require mpdf/mpdf aws/aws-sdk-php

mPDF can convert trusted HTML and CSS into a PDF. Its manual explicitly warns, “mPDF is not meant to receive HMTL/CSS from an outside user.” If users can edit templates or content, sanitize and validate that input on the server; browser-style sanitization alone is not sufficient.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Render an invoice into a string

<?php
require __DIR__ . '/vendor/autoload.php';

use MpdfMpdf;

$customerName = 'Ada Lovelace';
$invoiceNumber = 'INV-1007';
$amount = '$125.00';

// Escape values before inserting them into HTML.
$html = '<!doctype html>
<html><body>
<h1>Invoice ' . htmlspecialchars($invoiceNumber, ENT_QUOTES, 'UTF-8') . '</h1>
<p>Customer: ' . htmlspecialchars($customerName, ENT_QUOTES, 'UTF-8') . '</p>
<p>Amount due: ' . htmlspecialchars($amount, ENT_QUOTES, 'UTF-8') . '</p>
</body></html>';

$mpdf = new Mpdf(['tempDir' => __DIR__ . '/var/mpdf']);
$mpdf->WriteHTML($html);
$pdfBytes = $mpdf->Output('', 'S'); // Return PDF bytes, do not send them yet.

if ($pdfBytes === '') {
    throw new RuntimeException('The PDF renderer returned no data.');
}

Give the process a writable temporary directory, keep generated files outside the web root when possible, and apply authorization before generating a document for a requested account or order.

Option 1: save locally and serve through PHP

Local storage is suitable for one server or a mounted durable volume. It is not durable by itself when you deploy multiple instances, replace a container, or lose a machine.

<?php
// Continue after $pdfBytes has been created.
$storageDir = __DIR__ . '/var/private-pdfs';
if (!is_dir($storageDir) && !mkdir($storageDir, 0700, true)) {
    throw new RuntimeException('Cannot create private PDF directory.');
}

$objectKey = 'invoice-' . bin2hex(random_bytes(16)) . '.pdf';
$path = $storageDir . '/' . $objectKey;
if (file_put_contents($path, $pdfBytes, LOCK_EX) === false) {
    throw new RuntimeException('Cannot write generated PDF.');
}

// Store $objectKey in your database. Do not expose the filesystem path.
$url = '/download.php?id=' . rawurlencode($objectKey);
header('Content-Type: application/json');
echo json_encode(['url' => $url], JSON_THROW_ON_ERROR);

Your download.php endpoint must authenticate the caller, look up the key in a database, verify ownership, and then stream the file with Content-Type: application/pdf. Do not concatenate an unchecked request parameter into a filesystem path; map an opaque database ID to a known path. Set a download disposition and a conservative cache policy for sensitive files.

Option 2: upload to Amazon S3

Upload the generated bytes

<?php
require __DIR__ . '/vendor/autoload.php';

use AwsS3S3Client;

$s3 = new S3Client([
    'version' => 'latest',
    'region'  => getenv('AWS_REGION'),
]);

$bucket = getenv('S3_BUCKET');
$objectKey = 'generated/invoices/' . date('Y/m/') . bin2hex(random_bytes(16)) . '.pdf';

$s3->putObject([
    'Bucket'      => $bucket,
    'Key'         => $objectKey,
    'Body'        => $pdfBytes,
    'ContentType' => 'application/pdf',
    'Metadata'    => ['document-type' => 'invoice'],
]);

// Persist $objectKey and document ownership in your database.

Use the SDK’s normal AWS credential chain (for example, an instance or task role) rather than embedding access keys in source code. Generate unique keys, set the content type, and keep the bucket private unless public delivery is an explicit requirement.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Return a public object URL only when intended

A public URL is easy to consume but allows anyone who knows or guesses the address to retrieve the PDF. Public access normally requires a deliberate bucket/object policy and conflicts with the safer default of keeping S3 Block Public Access enabled. Never grant public write permission just to simplify downloads.

If a CDN is required, CloudFront can keep the S3 origin private with origin access control. Route readers through the CDN URL instead of exposing the origin when origin restrictions matter.

Generate a private presigned URL

A presigned URL authorizes a specific S3 operation for a limited period without changing the bucket policy. The signer must have permission for the requested GetObject operation.

<?php
// Continue with the configured $s3, $bucket and $objectKey.
$command = $s3->getCommand('GetObject', [
    'Bucket' => $bucket,
    'Key'    => $objectKey,
]);

$request = $s3->createPresignedRequest($command, '+15 minutes');
$signedUrl = (string) $request->getUri();

header('Content-Type: application/json');
echo json_encode([
    'url' => $signedUrl,
    'expires_in' => 900,
], JSON_THROW_ON_ERROR);

The expiry is a maximum, not a guarantee that the link will outlive the credentials used to sign it. Temporary credentials can expire sooner. Anyone you send the link to can reuse it during its valid window, so avoid logging full URLs and do not place them in publicly visible pages.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

CloudFront signed delivery

For private CDN delivery, CloudFront signed URLs or signed cookies can enforce an end time and, where configured, a start time or IP address/range restriction. This adds key-management and distribution configuration, but lets the S3 bucket remain private.

Public URL versus signed URL

Decision Public object URL Presigned URL
Who can retrieve it? Anyone allowed by the public policy who has the address Anyone holding the signed link until it expires
Expiry Normally none; access ends when policy or object changes Configured duration, limited by signer credential lifetime
Bucket privacy Requires intentional public delivery Bucket can remain private
Forwarding risk URL can be forwarded indefinitely while public Forwarded link works only during its validity window
CDN choice Optional; CloudFront can hide the origin S3 signing or CloudFront signing, depending on the delivery path

Store the object key, document ID, owner, content type, and creation time. When a user asks to download, authorize the request and create a new signed URL. Treat the URL itself as a secret rather than as the permanent record.

Return the URL from an HTTP endpoint

A typical endpoint should authenticate the caller, load the document record, check ownership or a sharing permission, generate the URL, and return JSON:

header('Content-Type: application/json');
http_response_code(200);
echo json_encode(['url' => $signedUrl], JSON_THROW_ON_ERROR);

For a browser download, issue a server-side redirect only after the same authorization check. Do not let clients choose an arbitrary S3 bucket, key, or expiration value.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Reliability, performance, and cleanup

  • Stream large inputs: Avoid loading user-uploaded source material into memory unnecessarily. For very large PDFs, use a temporary file and upload it as a stream where your SDK configuration supports that pattern.
  • Make retries safe: Generate the database document ID first and use a deterministic or recorded object key so a retry does not create untracked duplicates. Confirm the upload succeeded before marking the record ready.
  • Use lifecycle rules: Delete abandoned temporary files and define object-retention rules for documents that have a known legal or business lifetime.
  • Control concurrency: Queue expensive rendering jobs when requests can generate many PDFs at once; return a job ID and provide a status endpoint rather than holding an HTTP connection indefinitely.
  • Cache deliberately: A signed URL is cacheable by intermediaries unless response headers prevent it. For confidential PDFs, use private/no-store headers on your application response and avoid leaking links into analytics or referrer headers.

Troubleshooting common failures

“Class MpdfMpdf not found”

Composer dependencies are missing or the autoloader was not included. Run Composer in the deployment build and require vendor/autoload.php from the correct path.

mPDF cannot write temporary files

Set an explicit writable tempDir outside the public directory and verify the PHP process user has permission. Do not make the entire application directory world-writable.

The PDF is blank or malformed

Log the rendered HTML length and inspect the template for unescaped markup, unsupported CSS, broken image URLs, or output accidentally sent before the PDF bytes. Ensure the response is not mixing HTML warnings with binary PDF data.

S3 returns AccessDenied

Check the runtime role’s permission for s3:PutObject and s3:GetObject, the bucket and region values, and any explicit deny or encryption requirement. A signer cannot create a usable URL for an operation it is not allowed to perform.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The signed link expires early

Compare the requested expiry with the lifetime of the credentials that signed it. Refresh credentials and generate a new URL; do not assume a previously issued link can be extended.

Users see an XML “NoSuchKey” response

The database key does not match the uploaded key, the object was deleted, or the request points at the wrong bucket. Record the exact key returned by your upload operation and verify it before issuing a link.

A public URL returns 403

The bucket is private, Block Public Access is enabled, or a policy denies the request. Either keep the object private and use a presigned URL, or deliberately configure public/CDN delivery after reviewing the exposure.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

If your next step is turning a web page into an image or PDF for a generated report, ScreenshotNeo provides a single HTTP request rather than requiring you to operate a headless browser. It accepts consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each cleanup step can be disabled. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

After creating your PDF or report URL, you can capture a page with cURL:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

PHP:

<?php
import requests; r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90); open("shot.webp", "wb").write(r.content)

The PHP snippet above is intentionally shown as the supplied Python-style request; use the equivalent PHP HTTP client in your application. For Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' }); const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo also offers an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. Every plan includes its features; 1,000 screenshots per month are free with no card, and paid plans start at $5 for 3,000 shots. See the ScreenshotNeo API documentation and create a free account.

Security checklist

  • Escape template values and sanitize any user-controlled HTML before passing it to mPDF.
  • Keep S3 Block Public Access enabled unless public retrieval is a documented requirement.
  • Use least-privilege roles and never put cloud credentials in source control.
  • Authorize every download against a document owner or sharing rule.
  • Use random, non-guessable object keys and store them server-side.
  • Protect signed URLs as credentials and generate them only when needed.
  • Set retention and deletion rules for temporary and final files.

Frequently Asked Questions

Should I store the signed URL in my database?

No. Store the object key or document ID and generate a fresh signed URL after authorization; the signed URL can expire or become invalid when its signing credentials expire.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Can a presigned URL be revoked immediately?

Not generally by changing the URL itself. Delete or replace the object, invalidate the signing credentials, or use a server-controlled download endpoint when immediate revocation is required.

When is local storage preferable to S3?

Local storage can be adequate for a single server with a durable volume and modest traffic. Use shared or object storage when deployments are distributed, files must survive machine replacement, or multiple workers need access.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.