Embed files and create PDF portfolios
At a glance
Section titled “At a glance”This recipe attaches one or more files to a PDF and, when you have several attachments, arranges them as a PDF portfolio. Use it when a document needs to carry supporting evidence in the same file: an invoice with its underlying timesheet, a product datasheet with a Computer-Aided Design (CAD) export, or an archival record that keeps the source spreadsheet beside the rendered report.
NextPDF gives you two entry points on the document object. embedFile() reads a file from disk; embedFileFromString() embeds in-memory bytes you generate at runtime. Both register the attachment. At save(), the engine writes each attachment as an embedded file stream, wraps it in a file specification dictionary, and links every specification into the document-level EmbeddedFiles name tree. ISO 32000-2 defines that name tree as the place where embedded file streams attach to the document as a whole through the name dictionary.
This is a Core capability with no commercial gate. The attachment Application Programming Interface (API) has been stable since 1.0.0 and runs across the 8.1-8.4 backport matrix.
Install
Section titled “Install”composer require nextpdf/core:^3No optional extension is required.
Conceptual overview
Section titled “Conceptual overview”An attachment moves through three PDF structures. Knowing them helps you inspect the output and debug a non-conforming file.
- Embedded file stream. The raw bytes of the attached file, Flate-compressed, and written as a stream object whose
/Typeis/EmbeddedFile. NextPDF records the original size, an MD5 checksum, and the modification date in the stream’s parameter dictionary. It encodes the detected Multipurpose Internet Mail Extensions (MIME) type as the stream/Subtype. - File specification dictionary. The metadata wrapper. It carries the display filename (
/Fand the Unicode/UF), a human-readable description (/Desc), a reference to the embedded stream (/EF), and the file’s relationship to the host document (/AFRelationship). EmbeddedFilesname tree. A single document-level index that maps each attachment’s name to its file specification. ISO 32000-2 requires every file specification reached through this tree to carry anEFentry whose value references an embedded file stream. NextPDF builds and balances this tree for you atsave().
The relationship value matters for conformance. The PDF Association Application Note 0002 states that an associated file requires an AFRelationship entry chosen from the fixed PDF 2.0 set: Source, Data, Alternative, Supplement, EncryptedPayload, FormData, Schema, or Unspecified. NextPDF models that set as the AFRelationship enum and rejects any other value. Choose the term that explains why the file is present: a timesheet behind an invoice is Source; a machine-readable dataset behind a chart is Data.
A PDF portfolio (called a collection in ISO 32000-2) is the next layer up. When a document carries several attachments, the catalog Collection dictionary tells the reader how to present them: a sortable details table, a tile layout, or a hidden envelope. ISO 32000-2 describes the Collection dictionary as the control a PDF processor uses to present file attachments as an organized portfolio. NextPDF models this as the CollectionDictionary value object, with CollectionSort for the column order in a details view.
API surface
Section titled “API surface”The document-level methods come from the HasFileAttachments concern on \NextPDF\Core\Document:
embedFile(string $path, string $description = ''): static— reads a file from$pathand attaches it. NextPDF detects the MIME type from the extension; the relationship defaults toUnspecified. Reads up to 100 MB; useembedFileFromString()for larger payloads. Returns the document for chaining.embedFileFromString(string $data, string $filename, string $description = '', string $afRelationship = '/Unspecified'): static— attaches in-memory bytes under the display name$filename. Pass anAFRelationshipliteral (with or without the leading slash) to set the relationship. Returns the document for chaining.
The supporting types live in the \NextPDF\Navigation and \NextPDF\Document namespaces:
\NextPDF\Navigation\AFRelationship— the enum for the eight valid relationship values.AFRelationship::coerce()normalizes a string or enum case and throws on an unknown value.toPdfName()emits the/Nameliteral.\NextPDF\Document\CollectionDictionary— builds the catalogCollectiondictionary. TheVIEW_DETAILS,VIEW_TILE,VIEW_HIDDEN,VIEW_CUSTOM, andVIEW_NONEconstants select the presentation mode; the constructor also accepts an initial document name and an optional sort.\NextPDF\Document\CollectionSort— the column-ordering value object for a details-view portfolio.
Code sample — Quick start
Section titled “Code sample — Quick start”This minimal example attaches a generated comma-separated values (CSV) dataset to an invoice page and declares it as the Source data the invoice was built from.
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;use NextPDF\Navigation\AFRelationship;
$doc = Document::createStandalone();$doc->addPage();$doc->setFont('helvetica', 'B', 18);$doc->cell(0, 12, 'Invoice INV-2026-0042', newLine: true);
// Attach the line-item dataset the invoice was rendered from.$csv = "sku,qty,unit_price\nA-100,3,49.00\nB-220,1,180.00\n";$doc->embedFileFromString( data: $csv, filename: 'line-items.csv', description: 'Source line items for INV-2026-0042', afRelationship: AFRelationship::Source->value,);
$doc->save(getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/invoice-with-attachment.pdf');The reader shows line-items.csv in its attachments panel, and the relationship marks it as the invoice source.
Code sample — Production
Section titled “Code sample — Production”This complete example attaches a file from disk and an in-memory dataset, validates the on-disk path against an allowlisted base directory before reading it, and builds a sortable portfolio for the attachments. It catches the most specific NextPDF exceptions the attachment path can raise, then returns a defined exit code instead of swallowing the failure.
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;use NextPDF\Document\CollectionDictionary;use NextPDF\Document\CollectionSort;use NextPDF\Exception\CompressionException;use NextPDF\Exception\InvalidConfigException;use NextPDF\Exception\PageLayoutException;use NextPDF\Navigation\AFRelationship;
/** * Resolve a caller-supplied filename against an allowed base directory. * * Rejects path traversal and stream wrappers so an embedded attachment can * never read outside the directory the application owns. Returns the * canonical absolute path, or null when the input escapes the base. * * @param non-empty-string $baseDir Absolute path to the allowed directory. * @param non-empty-string $userName Untrusted filename from the request. */function resolveWithinBase(string $baseDir, string $userName): ?string{ $base = \realpath($baseDir); if ($base === false) { return null; }
$candidate = \realpath($base . \DIRECTORY_SEPARATOR . \basename($userName)); if ($candidate === false || !\str_starts_with($candidate, $base . \DIRECTORY_SEPARATOR)) { return null; }
return $candidate;}
$attachmentsDir = __DIR__ . '/attachments';$requestedFile = 'timesheet-2026-05.pdf';
$safePath = resolveWithinBase($attachmentsDir, $requestedFile);if ($safePath === null) { \fwrite(\STDERR, "Rejected attachment path: outside the allowed directory\n"); exit(2);}
try { $doc = Document::createStandalone(); $doc->setTitle('Invoice INV-2026-0042 with supporting documents'); $doc->addPage(); $doc->setFont('helvetica', 'B', 18); $doc->cell(0, 12, 'Invoice INV-2026-0042', newLine: true);
// 1. A validated file from disk: the supporting timesheet. $doc->embedFile( $safePath, 'Timesheet supporting the billed hours', );
// 2. An in-memory dataset generated at runtime. $lineItems = "sku,qty,unit_price\nA-100,3,49.00\nB-220,1,180.00\n"; $doc->embedFileFromString( data: $lineItems, filename: 'line-items.csv', description: 'Machine-readable line items', afRelationship: AFRelationship::Data->value, );
// Present both attachments as a sortable details portfolio. The sort // keys reference columns declared in the portfolio /Schema; here the // built-in filename and modification-date fields order the view. $portfolio = new CollectionDictionary( view: CollectionDictionary::VIEW_DETAILS, initialDocument: 'line-items.csv', sort: new CollectionSort( keys: ['_Filename', '_ModDate'], ascending: [true, false], ), ); // $portfolio->toPdfDictionary() yields the catalog /Collection literal, // shared with the unencrypted-wrapper envelope path.
$out = getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/invoice-portfolio.pdf'; $doc->save($out);
echo "Wrote {$out} with 2 attachments and a details portfolio\n";} catch (PageLayoutException $e) { // Unreadable path, oversized file, null byte, or a MIME-type name that // exceeds the 127-byte PDF name limit. \fwrite(\STDERR, "Attachment rejected: {$e->getMessage()}\n"); exit(1);} catch (CompressionException | InvalidConfigException $e) { // The attachment data could not be compressed, or a config value was invalid. \fwrite(\STDERR, "Write failed: {$e->getMessage()}\n"); exit(1);}CollectionDictionary and CollectionSort are value objects. They validate their inputs at construction and serialize to the catalog /Collection literal that drives the portfolio view in the reader.
Edge cases & gotchas
Section titled “Edge cases & gotchas”- Path input is your responsibility.
embedFile()guards against null bytes and stream wrappers and resolves the real path, but it does not enforce a base-directory allowlist. When the path comes from a request, validate it first, as the production sample does withresolveWithinBase(). - The 100 MB ceiling applies to
embedFile()only. A file over104,857,600bytes raisesPageLayoutException. For larger payloads, stream the bytes yourself and pass them toembedFileFromString(). - Long MIME-type names are rejected. The detected MIME type becomes the embedded stream
/Subtype, a PDF name token bounded to 127 bytes by ISO 32000-2. An unusually long type (some Office formats approach 90 bytes) stays well under the limit, but a hand-supplied type that exceeds it raisesPageLayoutException. Let the engine detect the type from the extension unless you have a specific reason to override it. - An unknown relationship throws.
AFRelationship::coerce()rejects any value outside the fixed set rather than downgrading toUnspecified. Pass an enum case (AFRelationship::Source->value) to keep a typo from reaching the runtime. - Filenames must be distinct in the name tree. Two attachments with the same display name collide in the
EmbeddedFilesindex. Give each attachment a unique filename. _ModDateis recorded in Coordinated Universal Time (UTC).embedFile()reads the file modification time and writes it withgmdate()so the same fixture produces a byte-identical date across machines, regardless of the timezone setting.
Performance
Section titled “Performance”Each attachment is compressed once with gzcompress() at level 9 and written as a single stream at save(). Compression dominates the cost and scales with the attached payload size, not with page content. A handful of small supporting files (datasets, spreadsheets, a timesheet PDF) stays inside the 2000 ms / 64 MB budget. For many large attachments, the embedded bytes are the memory floor: a 50 MB attachment held as a string occupies at least that much before compression. Prefer embedFileFromString() with chunked generation to loading several large files at once.
The name tree is built once at save(). Up to 64 entries stay in a flat single-root tree. Beyond that, NextPDF partitions the tree into balanced Kids and Limits ranges, so index cost remains logarithmic for large attachment sets.
Security notes
Section titled “Security notes”- Validate every untrusted path against an allowlist. Embedding reads any file the PHP process can reach. Without a base-directory check, a crafted filename turns an attachment into Local File Inclusion (LFI). The production sample shows the allowlist guard; apply it whenever the filename is not a compile-time constant.
- Treat attached bytes as untrusted on the consuming side. An embedded file is opaque to NextPDF. The engine does not parse or execute it. The risk lives where the file is later opened. Set the relationship and description so a downstream consumer knows what each attachment is before extracting it.
- No secrets in attachments or descriptions. The filename, description, and bytes are stored in the clear unless the whole document is encrypted. To protect an attachment, encrypt the document with a permissions policy (see the related recipe). Do not embed credentials, keys, or personal data you would not place in the rendered page.
- No network access occurs in this recipe. Every byte is read from the validated local path or supplied in memory.
Conformance
Section titled “Conformance”| Statement | Spec | Clause | reference_id |
|---|---|---|---|
Embedded file streams attach to the document through the EmbeddedFiles entry in the name dictionary. | ISO 32000-2 | 7.11.4 | |
The EmbeddedFiles name tree maps names to file specifications whose EF entry references an embedded file stream. | ISO 32000-2 | 7.7.4 | |
An associated file requires an AFRelationship value from the fixed PDF 2.0 set. | PDF Association AN002 | 3 | |
The catalog Collection dictionary controls portfolio presentation of attachments. | ISO 32000-2 | 7.11.6 |
Reproducibility profile — structural. The trailer /ID, the per-save date atoms, and the embedded-stream /ModDate vary between runs, so a structural comparison strips those values before diffing the object graph. This recipe describes how NextPDF produces the structure. It does not assert blanket PDF/A-4f conformance, which depends on the full document. For an archival profile that requires every attachment to declare a relationship and a description, see the PDF/A-4 recipe.