Skip to content

An API that refuses to guess

Spec: ISO/IEC 25010 Spec: ISO 32000-2 Evidence: Code-backed

NextPDF makes you say what you mean. Where intent changes the bytes — a signature level, an output destination, a conformance target — it is a required explicit argument, not something the engine infers from context.

This page shows that stance in the engine’s own source: the method signatures, the named arguments, and the points where an ambiguous input is rejected before any byte is produced.

A guess is a decision made on your behalf without telling you. For a text field, that is mildly annoying. For a PDF, it is a latent defect, because what you ship is often a legal or archival artifact whose correctness is checked later by someone else with a validator.

Consider a signature. Its digest is computed over a declared byte range that deliberately excludes the signature value itself ( Spec: ISO 32000-2, §12.8 ). An API that quietly “helps” — rewriting structure, inferring a level, padding a placeholder — has not helped. It has changed the bytes a signature was supposed to protect. The guess that seems friendly at the call site becomes the production incident weeks later. They are the same line of code.

  • If a choice changes the output and has no safe default, NextPDF makes it a required argument, not an inferred one.
  • Optional arguments that read ambiguously are named, so the call site states intent (newLine: true, not a bare true).
  • Inputs that could be unsafe are validated before rendering, and rejected with a typed exception that names the cause.
  • A document instance is use-once: it is built, emitted, and discarded. There is no reset(), so there is no “is this thing reused?” guessing.
  • The engine never emits a plausible-looking artifact in place of the one you asked for. It refuses instead.

The mechanism is plain, and that is the point. It is the type system, named arguments, enums instead of magic strings, and a small number of deliberate guard clauses placed before output.

The table contrasts a few ambiguous inputs. For each one, it shows what a library that “helps” would infer, and what NextPDF does instead. Every NextPDF column is a behaviour quoted from the source shown later on this page.

Ambiguous inputWhat a guessing library doesWhat NextPDF does
An orientation string like "portait"Falls back to a default and renders anywayaddPage() takes the Orientation enum, not a string — a typo is a type error, not a silent default
A bare trailing true to cell()Picks whichever boolean position it assumes you meantThe boolean is named at the call site (newLine: true); an unnamed literal is the smell the API removes
A php:// wrapper or traversal path to save()”Tries its best” and writes somewhereRejected before the PDF is built, with a typed InvalidConfigException naming key, value, and expected type
setSignature() then save() while the high-level signer is unwiredEmits an unsigned file the caller believes is signedThrows NotImplementedException before producing bytes, naming the supported route
Reusing a Document instance for a second renderGuesses whether residual state still appliesNo reset() and no reuse path — a fresh instance per request via DocumentFactory, so there is no residual state to guess about

Intent is a required argument. The core contract, PdfDocumentInterface, takes geometry and alignment as typed value objects and enums, not loose primitives:

public function addPage(
?PageSize $size = null,
Orientation $orientation = Orientation::Portrait,
): static;
public function cell(
float $width,
float $height,
string $text = '',
bool|string $border = false,
bool $newLine = false,
Alignment $align = Alignment::Left,
bool $fill = false,
): static;

Orientation and Alignment are enums, so the call cannot pass "portait" and have it silently mean “default”. Where a default exists, it is a safe one (portrait, left, no border), not a guess about what you probably wanted.

Ambiguous booleans are named at the call site. Across the examples that serve as the de-facto API reference, the same shape recurs:

$document->cell(0, 15, 'Hello, NextPDF!', newLine: true);
$document->setSignature(certInfo: $certInfo, level: SignatureLevel::PAdES_B_B);
$pdf = $document->output(dest: OutputDestination::String);

newLine: true is unmistakable. A bare trailing true would not be. The signature level is SignatureLevel::PAdES_B_B, an enum case — never a string the engine has to interpret. The output destination is OutputDestination::String, so “give me the bytes, no HTTP headers, no file” is stated. It is not inferred from whether a filename was passed.

Unsafe input is rejected before a byte is written. save() validates the destination path before it builds the PDF:

public function save(string $path): void
{
// Reject stream wrappers and null bytes
if (\str_contains($path, "\0") || \preg_match('#^[a-zA-Z]+://#', $path)) {
throw new InvalidConfigException(
configKey: 'output_path',
givenValue: $path,
expectedType: 'valid_path',
);
}
// Resolve the parent directory to prevent path traversal
$dir = \dirname($path);
$realDir = \realpath($dir);
if ($realDir === false) {
throw new InvalidConfigException(
configKey: 'output_path',
givenValue: $dir,
expectedType: 'existing_directory',
);
}
// ... only now is the PDF built and written atomically
}

The engine does not “try its best” with a php:// wrapper or a traversal path. It refuses, and the exception names the key, the value, and what was expected.

The engine refuses rather than emit a misleading artifact. The strongest form of refusing to guess is declining to produce output at all when that output would be untruthful. When a high-level signature is configured but the writer seam that would actually sign is not wired, the build path throws before producing bytes, instead of emitting an unsigned file the caller believes is signed:

if ($this->padesOrchestrator !== null) {
throw new NotImplementedException(
feature: 'Document::setSignature()->save()/output()/getPdfData()',
followUp: 'The high-level PAdES writer seam is not yet wired ... '
. 'Produce a signed PDF via the direct two-phase '
. 'PadesOrchestrator::signDocument() then finalizeSignature() '
. 'buffer API ...',
);
}

An unsigned PDF that looks signed is the precise kind of plausible-looking wrong artifact this principle exists to prevent. The same stance appears in the strict CSS path. An unregistered spec deviation throws a StrictModeViolation at the point of detection, rather than rendering an approximation and leaving the deviation undetected.

Use-once removes a whole class of guesses. A Document is disposable — built, emitted, and discarded. There is no reset() and no reuse path. A long-running worker creates a fresh instance per request through DocumentFactory. The engine never has to guess whether residual state from a previous document is still meaningful, because there is none by construction.

This page is Evidence: Code-backed : every shape above is quoted from the engine’s own source and its examples, not paraphrased from intent.

  • The typed, enum-bearing signatures are the public contract in PdfDocumentInterface. The named-argument call style is the consistent form across the canonical examples that act as the de-facto API reference.
  • The pre-render path validation, with its typed InvalidConfigException, and the refuse-before-emit NotImplementedException guard are quoted verbatim from the output path of the document façade.
  • The standards anchor is Spec: ISO/IEC 25010, §3.32 — user error protection, the quality property a refuse-to-guess API exists to satisfy at the call site. The second anchor is Spec: ISO 32000-2, §12.8 , which is why guessing around a signed document is never harmless. The digest covers a declared byte range that excludes the signature value, so any silent rewrite invalidates it.

A small, complete program follows. Every line that could be ambiguous states its intent. The one unsafe input is refused before any work is done.

<?php
declare(strict_types=1);
use NextPDF\Contracts\OutputDestination;
use NextPDF\Core\Document;
use NextPDF\Exception\InvalidConfigException;
use NextPDF\ValueObjects\PageSize;
use NextPDF\Contracts\Orientation;
$document = Document::createStandalone();
$document->setTitle('Quarterly Report');
// Intent is explicit: a typed page size and an Orientation enum case,
// not a string the engine has to interpret.
$document->addPage(PageSize::a4(), Orientation::Landscape);
$document->setFont('helvetica', 'B', 16);
// Ambiguous boolean is named, so the call reads as intent.
$document->cell(0, 12, 'Quarterly Report', newLine: true);
try {
// Unsafe path is rejected before a byte is built.
$document->save('php://output/report.pdf');
} catch (InvalidConfigException $e) {
// "Invalid configuration for key "output_path": expected valid_path, ..."
error_log($e->getMessage());
// The String destination is explicit: bytes only, no HTTP headers,
// no file side effect. Nothing is inferred from a missing filename.
$bytes = $document->output(dest: OutputDestination::String);
}

There is no path where this program quietly does the wrong thing. It states intent and proceeds, or it names the problem and stops.

The frequent objection is “this is just verbosity”. It is not verbosity. It is the absence of hidden defaults. A bare true is shorter than newLine: true by exactly the amount of clarity it removes. The engine trades a few characters at the call site for the elimination of a category of bug — the one where the code compiles, runs, produces a file, and is wrong.

A related misconception is that fail-fast means “throws a lot”. In normal use NextPDF throws nothing. Valid input flows through. The guards fire only on inputs that are genuinely ambiguous or unsafe — precisely the inputs you want to hear about immediately, not the ones you want guessed.

Refusing to guess applies to intent and safety, not to every convenience. NextPDF still has safe defaults: portrait orientation, left alignment, no border. The principle is that a default is offered only where it is safe and unsurprising, and never where the wrong inference produces a wrong document.

This page demonstrates the principle on the core public API surface (the document façade, its contract, and the output path). Subsystems have their own entry points, and each documents its own validation behaviour. The shapes quoted here are current as of this review. They illustrate the pattern; they are not an exhaustive catalogue of every guard in the engine.

The fail-fast guards described are correctness and safety guards. They are not a security boundary on their own. Input validation is one layer. The design philosophy and the security documentation describe the wider stance.

  • Code-backed (evidence level) — a page whose claims are checked against the engine’s own source or a runnable example, quoted rather than paraphrased.
  • Fail fast — rejecting an invalid input at the earliest point, with a clear cause, instead of proceeding and failing obscurely later.
  • Named argument — a PHP call-site syntax (newLine: true) that binds a value to a parameter by name, making an otherwise ambiguous literal self-describing.
  • Use-once lifecycle — the disposable Document contract: instantiate, write, save, discard. No reset(), no reuse. Workers create a fresh instance per request through DocumentFactory.
  • PAdES — PDF Advanced Electronic Signatures, the ETSI profile family for PDF signing. Expanded on first use; covered in depth on the signing pages.