rasuvaeff / doc-exec
Doctest for PHP: execute fenced code blocks from README/docs against the current codebase and diff the output
Requires
- php: 8.3 - 8.5
- ext-tokenizer: *
- nikic/php-parser: ^5.7
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- rasuvaeff/property-testing-testo: ^0.6
- rasuvaeff/rector-named-literals: ^1.0
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
This package is auto-updated.
Last update: 2026-08-21 10:41:59 UTC
README
Doctest for PHP: executes php doc-exec fenced code blocks from your README
against the current codebase and diffs the result. Catches doc-rot — code
examples that quietly stop matching the code they document — the way
cargo test --doc does for Rust.
Using an AI coding assistant? llms.txt contains a compact API reference you can share with the model.
Requirements
- PHP 8.3+
ext-tokenizernikic/php-parser^5.7 (a code block is split into statements by a real PHP parser, so every construct the language allows stays intact)
Installation
composer require --dev rasuvaeff/doc-exec
How it works
A fenced code block only runs when it opts in with the php doc-exec info
string — a bare ```php block is illustrative and never executed. Each
top-level PHP statement inside a block may carry a trailing marker comment:
```php doc-exec 2 + 3; // => 5 intdiv(1, 0); // throws DivisionByZeroError echo "done"; // outputs done network_call(); // skip: hits a real API ```
| Marker | Checks |
|---|---|
| (none) | The statement must not throw. |
// => <expr> |
The statement's value matches <expr>, compared via var_export(). |
// throws <FQCN>[ | <substring>] |
The statement throws an instance of <FQCN>, optionally with a message containing <substring>. |
// outputs <text> |
The statement's stdout, trimmed, equals <text>, trimmed. |
// skip or // skip: <reason> |
The statement is never executed — documentation only. |
The skip grammar is deliberately strict: // skip this in production is
prose and stays executable. A lenient rule would fail open — the statement
would silently never run while its block still reported PASS.
// => compares with var_export(), which compares state, not identity:
two distinct objects with equal properties compare equal, and 0.1 + 0.2 is
correctly distinguished from 0.3. The marker only applies to an expression
statement — on echo, if or foreach it is reported as a mistake rather
than silently doing nothing.
Blocks under the same Markdown heading share one variable scope, in document
order (setup → assert, like a doctest); a new heading starts a fresh scope.
Every document runs in its own child php process with the project's
vendor/autoload.php on the include path — never eval(), never the process
running doc-exec itself.
Because blocks are parsed rather than pattern-matched, anything PHP allows is
one statement — closures, if/else, try/catch, match, anonymous
classes, do/while:
$double = function (int $n): int { return $n * 2; }; if ($double(4) > 5) { $size = 'big'; } else { $size = 'small'; } $size; // => 'big'
A block that is not valid PHP is reported against that block, with the line number inside the block; the other blocks sharing its scope still run.
2 + 3; // => 5
Usage
Command line
vendor/bin/doc-exec # zero-config: checks ./README.md vendor/bin/doc-exec docs/*.md # explicit file list vendor/bin/doc-exec --bootstrap=tests/bootstrap.php README.md # explicit autoloader vendor/bin/doc-exec --timeout=60 docs/slow.md # wall-clock budget per scope group vendor/bin/doc-exec --help
Exit code is 0 when every block passes, 1 otherwise — wire it into CI or
a pre-commit hook. An unknown option is a usage error (exit 2), never a file
name.
Each scope group runs under a wall-clock deadline — 30 seconds by default,
--timeout=<seconds> to change it. A block that loops forever is killed and
reported as a failure, so a documentation mistake cannot wedge a CI job.
Programmatically
use Rasuvaeff\DocExec\MarkdownExtractor; $markdown = "```php doc-exec\n1 + 1;\n```"; $blocks = (new MarkdownExtractor())->extract($markdown, 'inline.md'); count($blocks); // => 1
function riskyDivide(int $a, int $b): int { return intdiv($a, $b); } riskyDivide(1, 0); // throws DivisionByZeroError echo "done"; // outputs done 1 + 1; // skip: illustrative only, never executed
Public API
| Class | Description |
|---|---|
DocExec |
Facade: check(string $path): DocumentResult. Constructor takes bootstrap: and timeoutSeconds:. |
MarkdownExtractor |
Extracts php doc-exec (or, with strict: true, bare php) fenced blocks. |
DocumentResult |
passed(): bool, failedIds(): list<string> (stable ids of failing blocks). |
BlockResult |
One fenced block's outcome: its statements, pass/fail, stableId. |
StatementResult |
One statement's outcome, its Statement, its ParsedMarker, and the failure message. |
StatementOutcome |
Pass / Fail / Skip — the type of StatementResult::$outcome. |
Statement\Statement |
One statement: code, line (first line of the statement), trailingComment, kind. |
Statement\StatementKind |
Expression / Import / Other — what PHP considers the statement. |
Statement\StatementParseError |
Thrown for a block that is not valid PHP; carries blockLine. |
Marker\ParsedMarker |
A parsed marker: type, plus the payload fields for that type. |
Marker\MarkerType |
None / Equals / Throws / Outputs / Skip / Invalid. |
Cli\Arguments |
parse(list<string> $arguments, string $workingDirectory): Arguments, and usage(). |
Cli\UsageError |
A command line that could not be understood, as opposed to a failing document. |
StableId |
compute(string $file, int $blockOrdinal, string $code): string — sha256 of the file path, the block's ordinal and its whitespace-normalised code. The id holds while all three do; editing the block, moving it past another executable block, or renaming the file produces a new one. |
AutoloadFinder |
find(string $startDir): ?string — walks up for vendor/autoload.php, stopping at the project boundary. |
Report\ConsoleReporter |
render(list<DocumentResult> $results): string. |
Security
- Blocks execute arbitrary PHP with the permissions of the process running
doc-exec— only run it against documentation you trust, exactly like running the project's own test suite. - Use
// skip: <reason>for any statement with side effects you do not want running in CI (network calls, filesystem writes outside a temp dir). - The bootstrap path is never derived from untrusted input. The upward search
stops at the first
composer.json/.gitabove the document, so it will not silently borrow a parent project's autoloader; pass--bootstrapexplicitly when the layout is unusual. - The child process is bounded by wall-clock time, not by memory or filesystem access: a block still runs with the full permissions of the user running doc-exec.
- The generated script and the results it reports are written to temporary
files created with
tempnam()(mode 0600, exclusive), executed with the array form ofproc_open— no shell is involved at any point — and both files are removed even if the run throws.
Examples
See examples/ for a runnable, self-checking sample document.
| Script | Shows | Needs server? |
|---|---|---|
examples/sample.md |
All four markers, closures and control flow, plus scope-by-heading — checked via bin/doc-exec |
no |
examples/programmatic.php |
Reading per-statement results through the DocExec API instead of the CLI |
no |
Development
No PHP/Composer on the host — run in Docker via the composer:2 image:
docker run --rm -v "$PWD":/app -w /app composer:2 composer install docker run --rm -v "$PWD":/app -w /app composer:2 composer build docker run --rm -v "$PWD":/app -w /app composer:2 composer cs:fix docker run --rm -v "$PWD":/app -w /app composer:2 composer test docker run --rm -v "$PWD":/app -w /app composer:2 composer release-check
Or with Make:
make install
make build
make cs-fix
make test
make test-coverage
make mutation
make release-check