Back to blog

Transpiling sebastian/exporter: What it took to get to 92.5%

sebastian/exporter is a small but load-bearing library in the PHP ecosystem. Its job is to convert any PHP value into a human-readable string, the kind that shows up in PHPUnit failure messages telling you what the assertion received versus what it expected. At roughly 600 lines of PHP, it looks simple. Getting the transpiled version to 92.5% on its test suite required Pext's runtime to fully cover three PHP behaviours that are easy to take for granted.

What exporter does

The library exposes a single main method, export($value), which walks a PHP value and produces a formatted string. Scalars are trivial. Arrays and objects require recursive traversal, which immediately raises the question of what happens when a structure contains a reference to itself. The exporter handles this by tracking which values are currently being processed and substituting *RECURSION* when it detects a cycle.

That tracking mechanism, and two areas of PHP object semantics, are what made the exporter non-trivial to transpile correctly.

Recursion detection and destructor timing

The exporter's recursion guard relies on a PHP pattern where cleanup is tied to object lifetime: when a value starts being exported, a guard registers it in the tracking context; when the guard goes out of scope, the entry is removed. In PHP this is guaranteed by the language. No explicit cleanup call is needed. The tracking context is always left in a clean state when a function returns.

The difficulty was that JavaScript does not share this guarantee. In a garbage-collected runtime there is no moment equivalent to "variable goes out of scope, destructor fires now." The guard objects were staying alive past the point where PHP would have destroyed them, which meant their cleanup code never ran. The tracking context accumulated entries that should have been removed, and from that point every subsequent export call saw phantom recursion where there was none. The symptom was *RECURSION* appearing in output for flat, non-circular values, with each call degrading further.

Pext's core handles destructor timing correctly, which is what makes the pattern work in the transpiled code. Once that was in place, the entire class of recursion detection failures cleared.

Object-to-array casting

The exporter uses PHP's array cast to enumerate an object's properties for display. For ordinary user-defined classes this is straightforward. PHP's built-in Exception and Error classes are a different matter: their standard properties are part of PHP's C core rather than the ordinary property model, and they do not appear automatically in a cast result unless the runtime explicitly handles them.

Pext's representations of Exception, Error, and their subclasses produce a cast result that matches PHP's output exactly: the standard exception fields followed by any properties defined on the concrete class. Before this was in place, casting an exception in the transpiled code returned an incomplete result, which made exception output in the exporter unreadable and caused a broad category of PHPUnit assertion failures to produce wrong messages.

Property visibility

PHP's array cast encodes property visibility into the key names it produces. Public, protected, and private properties each carry a distinct encoding. The exporter reads this to label each property correctly in its output.

Pext's cast implementation reproduces the full encoding, including the correct ordering when properties are distributed across an inheritance hierarchy. This is the kind of detail that rarely surfaces in documentation but shows up immediately when comparing expected and actual test output character by character. Getting it right means every assertion failure message that displays an object's internals is accurate rather than misleadingly labelling all properties as public.

Where we are

The exporter now passes 92.5% of its test suite. The three areas above account for the bulk of what moved the needle. The remaining failures are concentrated in edge cases around formatting of deeply nested structures, a small number of floating-point representation differences, and some SPL data structures that are still in progress in the runtime.

The improvements feed directly into PHPUnit: every assertion failure message goes through the exporter to produce the "Expected" and "Actual" values. A correct exporter means PHPUnit failure output is readable and accurate, which makes the remaining PHPUnit test failures straightforward to interpret rather than obscured by bad formatting.

The exporter is listed in our open source showcase alongside the other sebastian libraries and PHPUnit itself. The runtime work that unblocked it applies across all transpiled codebases, not just this library.