kphoen/rusty
Rusty brings Rust-inspired Result and Option types to PHP and Laravel, enabling explicit error handling and nullable values without exceptions or scattered null checks. Fluent helpers make safer, more expressive code easy to write.
Rusty brings Rust-style doctests to PHP: fenced code blocks inside docblocks are extracted, turned into executable tests, and run alongside (or instead of) your normal suite. The pitch is simple — if the example in your docs stops working, CI goes red.
composer require --dev kphoen/rusty
Where to look first, in order:
TestCase/TestSuite subclass vs. a standalone binary) is the one thing you must confirm before wiring anything.tests/ directory — it doubles as the spec for which markdown constructs are supported (fenced ```php blocks vs. indented blocks, how the expected output/exception is expressed).src/ — when an example doesn't do what you expect, the parser is what you'll be reading.Most projects wire it as a dedicated PHPUnit test case pointed at a directory of source files:
// tests/DocumentationTest.php
use KPhoen\Rusty\PHPUnit\DocTestCase; // exact name per README
final class DocumentationTest extends DocTestCase
{
protected function paths(): array
{
return [__DIR__ . '/../src'];
}
}
First use case: pick one small, stable value object or factory and document it.
/**
* Create a money value from an integer number of cents.
*
* ```php
* $money = Money::fromCents(1500, 'EUR');
* assert($money->getAmount() === 1500);
* assert((string) $money === '15.00 EUR');
* ```
*/
public static function fromCents(int $amount, string $currency): self
Run it, watch it pass, then deliberately break the implementation and confirm the doc suite fails. If it doesn't fail, you have an assert() configuration problem, not a Rusty problem (see Gotchas).
Docblocks as the contract, unit tests as the edge cases. Doc examples should read like usage documentation: the happy path plus the one surprising behaviour. Leave exhaustive matrix testing to PHPUnit. A good ratio is one code block per public method, 3–8 lines each.
Self-contained blocks. Treat every block as an isolated script. Rebuild fixtures inside the block rather than relying on state from a previous example or from the test file — ordering and shared static state are not guarantees you want to depend on.
Namespace inheritance. Examples are typically executed in the namespace of the file they live in, so unqualified class names resolve. Do not assume use statements from the file carry over; if a block needs a foreign class, either fully qualify it or put the use inside the block. Confirm this against the parser, because it is the single most common source of "works in the file, explodes in the doc test".
Documenting failures explicitly. Prefer a visible try/catch over a magic "expected exception" marker:
/**
* ```php
* try {
* Money::fromCents(-1, 'EUR');
* throw new \LogicException('should have thrown');
* } catch (\InvalidArgumentException $e) {
* assert($e->getMessage() === 'Amount must be positive');
* }
* ```
*/
It reads better in rendered docs and doesn't depend on harness-specific annotation support.
Namespace the suite in CI. Give it its own <testsuite> so you can run --testsuite documentation in seconds on every PR, and keep it out of the slow integration job. A red doc suite means stale README — that's the whole value proposition, so make it fast and visible.
Layer with static analysis deliberately. PHPStan/Psalm parse docblocks for @param/@return but will not type-check code fences by default; you're getting runtime verification only. That's fine — just don't assume the examples are also statically verified.
Migration strategy. Start with the public API surface of one or two namespaces. Retrofitting every class is a large diff with low payoff, and this package will not be getting fixes, so keep the blast radius small enough that you could delete the doc suite in an afternoon.
assert() silently does nothing. In any production-ish php.ini, zend.assertions=-1 means every example "passes" without running its checks. zend.assertions cannot be changed at runtime with ini_set() for the -1 case — set it in CI explicitly: php -d zend.assertions=1 -d assert.exception=1 vendor/bin/phpunit. This is the number-one way teams adopt Rusty and get zero value.TestCase/TestSuite; a PHPUnit upgrade will likely break it before your own tests break. Forking is the mitigation.*/ inside a code fence terminates the docblock. So does an unbalanced /*. Keep examples free of block-comment syntax, and be careful with regex literals and paths containing */.*. Mixed tabs/spaces or a stray un-indented line can truncate or corrupt extraction. If a block misbehaves, the fix is almost always whitespace.echo/print ordering all become assertions. Suppress noise, or prefer inline assert() over output matching.use lines, or inject a shared fixture setup — cheaper than fighting the defaults.How can I help you explore Laravel packages today?