Skip to content

Contracts / Streaming

The streaming domain includes two experimental interfaces: StreamingWriterInterface for incremental PDF output and CursorInterface for page-level content composition. Core ships a final, tested engine that implements both. The engine classes are internal, so you use the public experimental contract instead of implementing the engine yourself. Because the tier is experimental, the contract may change in a minor release with a prior deprecation notice. Pin tightly or wrap it behind your own adapter before you depend on it in production.

Terminal window
composer require nextpdf/core:^3

A streaming writer serializes each page as you compose it and may flush it to the output before the next page begins. Use it when a document can exceed the available memory budget. The in-memory writer holds the whole document. A streaming writer does not. StreamingWriterInterface defines a strict state machine. A fresh instance is CLOSED. open() moves it to OPEN and writes the PDF header to a caller-supplied stream. newPage() moves it to PAGING and returns a cursor. close() writes the cross-reference structure and the trailer, then moves it to a terminal CLOSED. A cross-reference stream maps each object number to its byte offset, as covered by ISO 32000-2 §7. Only one session runs per instance. After close() the instance is spent. The caller owns the stream resource. The writer writes to it but never closes it.

CursorInterface is the page-level write surface. You obtain a cursor from StreamingWriterInterface::newPage(), and it remains valid until you finalize it, until the next newPage() auto-finalizes it, or until close() invalidates it. Invalidation is permanent. A cursor cannot be reactivated. Every method on an invalidated cursor throws LogicException. The cursor writes raw content-stream operators, sets the active font, and writes positioned text. A content stream encodes page content as a sequence of graphics operators, as covered by ISO 32000-2 §8. The cursor is a low-level surface: it does not perform text shaping, bidirectional reordering, line breaking, or any layout. Those stay Document-level concerns. The single-cursor invariant holds throughout: at most one cursor is valid at any time.

Both interfaces are experimental, and Core ships a working engine behind them: a final StreamingWriterInterface implementation, its page cursor, and a discard sink used for memory benchmarking. These engine classes are internal and are not part of the public surface. To use streaming, depend on the experimental contract and let Core supply the implementation. The PHPDoc on each type points to the streaming-writer ADR for the lifecycle state machine and scope rationale. Because the tier is experimental, the contract signature may still change in a minor release with a prior deprecation notice. Pin tightly or wrap it behind your own adapter before you depend on it in production.

TypeKindKey membersStabilitySince
StreamingWriterInterfaceinterfaceopen(resource, Config), newPage(?PageSize): CursorInterface, close()experimental (shipped engine)3.1.0
CursorInterfaceinterfacewriteContent(string), setFont(string, string, float), writeText(float, float, string), finalizePage()experimental (shipped engine)3.1.0

open() throws InvalidArgumentException for a non-writable stream and LogicException if the writer is already open. close() is not idempotent. Calling it twice throws.

examples/contracts/streaming-quickstart.php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use NextPDF\Contracts\StreamingWriterInterface;
use NextPDF\Core\Config;
/**
* Drive a streaming writer through one page.
*
* The parameter is the experimental contract; Core supplies the
* implementation. Type-hint the interface and let the engine satisfy it.
*
* @param StreamingWriterInterface $writer A Core-supplied streaming writer.
* @param resource $stream A writable, caller-owned stream.
*/
function writeOnePage(StreamingWriterInterface $writer, $stream): void
{
$writer->open($stream, new Config());
$cursor = $writer->newPage();
$cursor->setFont('helvetica', '', 12.0);
$cursor->writeText(72.0, 720.0, 'Streamed page.');
$cursor->finalizePage();
$writer->close();
// The caller closes $stream after close() returns.
}

The function targets the experimental interface, so it stays decoupled from the engine class. Core injects a working implementation at the call site.

examples/contracts/streaming-production.php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use NextPDF\Contracts\StreamingWriterInterface;
use NextPDF\Core\Config;
use NextPDF\ValueObjects\PageSize;
use Psr\Log\LoggerInterface;
final readonly class LargeReportStreamer
{
public function __construct(
private StreamingWriterInterface $writer,
private LoggerInterface $logger,
) {}
/**
* Stream a multi-page report to a caller-owned file handle.
*
* @param resource $stream Writable file handle owned by the caller.
* @param list<list<string>> $pages One list of text lines per page.
*/
public function stream($stream, array $pages): void
{
$this->writer->open($stream, new Config());
try {
foreach ($pages as $lines) {
$cursor = $this->writer->newPage(PageSize::A4());
$cursor->setFont('helvetica', '', 11.0);
$y = 760.0;
foreach ($lines as $line) {
$cursor->writeText(72.0, $y, $line);
$y -= 14.0;
}
$cursor->finalizePage();
}
} finally {
$this->writer->close();
}
}
}

The finally block guarantees that the writer closes and the trailer is written, even when a page loop throws. The caller still owns and closes the stream.

  • Depend on the interface, not the engine class. The engine that implements both contracts is internal and is not part of the public surface. Do not new it or reference it by name. Type-hint StreamingWriterInterface and let Core supply the implementation.
  • The contract is experimental. Its signature may change in a minor release, with a prior deprecation notice. Pin tightly or wrap it behind your own adapter before you depend on it in production.
  • A cursor becomes invalid the moment the next newPage() or close() is called. Holding a stale cursor and calling a method on it throws LogicException. Finalize explicitly for clarity.
  • close() is not idempotent. Calling it twice is a caller bug, not a recoverable condition. The contract throws.
  • The writer never closes the stream. If you forget to close a caller-owned handle after close() returns, you leak a file descriptor.
  • The engine flushes each finalized page so resident memory does not grow with page count. The exact memory profile is an experimental-tier property and may shift between minor releases. Do not hard-code an assumption from one measurement.

The streaming design bounds peak memory. The shipped engine flushes each completed page and releases its buffer, so the resident set does not grow with page count, unlike the in-memory writer. The engine spills its cross-reference and page-tree bookkeeping to disk-backed temporary streams to keep the process footprint near constant. Concrete memory and wall figures are an experimental-tier property and may move between minor releases, so this page asserts no fixed number. The performance_budget of 1500 ms wall and 64 MB peak is the canvas envelope, not a contractual guarantee. Reproducibility is bitwise: the same content and configuration produce byte-identical output, which the engine’s golden-baseline tests pin.

The cursor’s writeContent() method is a low-level escape hatch. It appends the supplied bytes to the page content stream verbatim and does not validate operator syntax or semantics. Untrusted input passed to writeContent() produces a corrupt or malicious PDF. Treat that method as a trusted-input-only surface, and prefer writeText() for any caller-influenced text. The shipped cursor escapes text passed to writeText() for the PDF literal-string grammar, but it does not sanitize raw operators. The caller-owned-stream model is also a security property. The engine writes to the stream but never closes or reopens it, so it cannot redirect output. The runtime attack surface is real because the engine ships. Callers must never feed untrusted bytes to writeContent(), and the engine must honor the contract’s invariants.

ClaimStandardClauseEvidence
A content stream encodes page content as a sequence of graphics operators, which the cursor appends.ISO 32000-2§8
The writer emits a cross-reference structure mapping each object number to its byte offset at close.ISO 32000-2§7

Both clauses are glossary-pinned and paraphrased. NextPDF reproduces no normative text. The streaming-writer ADR referenced by the contract PHPDoc contains the lifecycle and scope rationale.

A tested streaming engine ships in the open-source Core behind these experimental contracts. The engine classes are internal, so you use streaming through the public contract rather than a concrete class name. NextPDF Pro and NextPDF Enterprise follow the same contract, so code written against StreamingWriterInterface in Core stays valid against a Premium implementation of the same contract. The experimental tier, not edition or availability, is the caveat. The signature may change in a minor release with a prior deprecation notice.