Back to blog

Week in review: 11 May 2026

A recap of what shipped during the week of 11 May. Eight items this week: dragonmantank/cron-expression brought to 100%, magic-method fixes for __call and __invoke, by-reference and copy-on-write straightened out across call boundaries, dynamic member syntax ($item::$$property), a new pext CLI with a pext.ini equivalent, the TypeScript pipeline finished end-to-end with auto-detection and tidier imports, native JS new for static instantiation while keeping the runtime factory for dynamic class names, and a cluster of smaller fixes around variadic type coercion, call_user_func by-ref, and grouped use declarations.

cron-expression at 100%

dragonmantank/cron-expression is at 100%. The cron parser itself was the easy part; the work was almost entirely underneath, in the datetime module: timezone arithmetic, DST transitions, the add / sub / diff family, the long tail of PHP-specific format characters in both directions, and a cache for Intl.DateTimeFormat that turned a slow run into a reasonable one. The full write-up is at Transpiling dragonmantank/cron-expression.

That makes five Pure Utilities packages at 100% (brick/math, composer/semver, doctrine/inflector, doctrine/lexer, and now cron-expression) and brings the datetime module up to a partially audited state.

Where this fits in the bigger picture: the open source showcase and last week's recap.

Magic methods: __call and __invoke

__call and __invoke went through a round of fixes. __call is invoked when an instance method is called that does not exist on the class, with the original method name as the first argument and a packed array of the original arguments as the second. The runtime was forwarding the call correctly in the simple case but mishandling a few cross-cutting concerns: visibility (a private method that does exist should not fall through to __call from outside the class), inheritance (the magic method should be looked up along the class hierarchy with PHP's exact ordering), and by-reference arguments (the packed array has to preserve reference semantics for any argument that was passed with &).

__invoke turns an instance into a callable: $obj($arg) dispatches to __invoke. The fix was on the call site rather than the method itself: the call resolver now correctly recognises any object whose class defines __invoke as a callable, including in the contexts where PHP itself checks for callability (is_callable, array_map, call_user_func, the typed-property callable declaration). The interaction with __call at this site (an __invoke that is itself routed through __call) is also handled.

Further reading: property visibility in sebastian/exporter and the PHPUnit conversion, both of which lean heavily on magic methods.

By-reference parameters, foreach by-ref, and copy-on-function-call

PHP's value semantics are deceptively subtle. Arrays are values, but they are also implicitly shared-with-copy-on-write under the hood, and the moment a reference (&) enters the picture the behaviour changes in ways that surprise even seasoned PHP developers. Pext has to match this exactly because libraries depend on it.

This week's work tightened three call paths. Passing an array as a parameter no longer triggers an eager copy: the receiving function gets a copy-on-write view, and the copy happens only if and when the callee writes to the array. foreach (...as &$x) now binds $x as a true reference into the iterated array, including the dangling-reference behaviour PHP exhibits after the loop (the $x variable remains a reference to the last element, with all the foot-guns that implies). And assigning into an array that is currently aliased by a reference now triggers the right copy-on-write path so that one aliasing branch can mutate without affecting the others.

The end effect is that the by-ref / by-value boundary in transpiled code matches what the same PHP code would do, even in the corners where PHP's behaviour itself is surprising.

Further reading: refcounting and destructor timing in sebastian/exporter, the other side of the same coin, and how the runtime is structured.

Dynamic member syntax

PHP supports several dynamic forms for accessing class members: $obj->$prop, $obj->{$expr}, $class::$$property, $class::{$expr}(), and so on. The grammar around these is layered, and the transpiler was previously bailing on a few of the deeper combinations. The case that surfaced this week was $item::$$property: a static member access where the property name itself is a variable variable. That form is now emitted correctly, along with the surrounding combinations that share the same parser path.

Further reading: caller-scope passing for eval-generated code in composer/semver, where another dynamic-resolution path had to be handled correctly.

The pext CLI and pext.ini

Until now, running a transpiled project meant invoking the bootstrap script with the right environment variables and arguments. That is fine for development and for CI, but it is not what a user expects. The week's main piece of new surface is the pext CLI: a single command that mirrors the php binary's usage so that a converted script runs the same way the original did.

The CLI reads a pext.ini file that fills the role of php.ini: target PHP version, memory and time limits, error-reporting level, default timezone, the list of runtime modules to load, and a few Pext-specific knobs (currently around encoding handling and the calculator selection). The same flags can be overridden on the command line. The intent is that a project converted with Pext gets a runtime config that looks and feels familiar to anyone who has ever pointed at a php.ini.

Try it: book a demo, or see the features overview for what the CLI sits on top of.

TypeScript pipeline finished end-to-end

The experimental TypeScript output mode landed two weeks ago. This week it became a real pipeline. The transpiler now emits a complete .ts project (sources, module wiring, entry points) that compiles cleanly and runs end-to-end through the same runtime. The CLI auto-detects whether the project on disk is JavaScript or TypeScript and dispatches accordingly, so the same pext invocation works in either case without flags.

A second improvement targeted the import surface: the generator was previously emitting an import statement for every named symbol it might reference, even when the symbol was unused in the final emitted code. The new pass drops imports that no surviving statement actually references, which makes both the JS and the TS output smaller and significantly easier to read. No types are derived from PHP yet; that remains the next major step on the TS side.

Where this started: the experimental TypeScript output mode, two weeks ago. Why it matters: Leaving PHP, Part 3 on the type-system gap.

Native new for static instantiation

Object creation in Pext historically went through a runtime factory: new Foo($a) was emitted as a call into the runtime that resolved the class name, looked up the constructor, ran the initialisation protocol, and returned the instance. That was necessary to handle PHP's dynamic-instantiation forms (new $class, (new ReflectionClass($name))->newInstance(...)), but it cost a lever in the hot path even for the overwhelming majority of call sites that name the class statically.

The transpiler now distinguishes the two cases. When the class is known at conversion time, it emits a native JavaScript new ClassRef(...args), with the constructor and initialisation protocol baked into the class itself. When the class is determined dynamically, it still goes through the runtime factory. The result is faster code on the common path without losing the dynamic case.

Why this matters: the runtime model is from a different era and Pext vs AI vs manual rewrite both touch on the performance side of the trade-off.

Variadic coercion, call_user_func by-ref, grouped use

Three smaller fixes that share the theme of "matching PHP at the call boundary".

Type coercion of variadic parameters now matches PHP's rules. A function declared as function f(int ...$xs) was previously skipping the coercion pass on the rest arguments and letting non-int values through to the body; the variadic parameter is now treated as a typed parameter applied element-wise, with the same coercion (or strict-mode rejection) as a positional typed parameter.

call_user_func and call_user_func_array were handling by-reference arguments inconsistently. PHP's rule is specific and slightly surprising: call_user_func deliberately does not pass arguments by reference even if the callable's signature declares them as such (you get a warning), while call_user_func_array does. The runtime now follows that rule exactly, including the warning emission, which closes a small but persistent source of test failures.

The grouped-use form (use Foo\Bar\{ClassA, ClassB, ClassC};) is now supported by the transpiler. The grammar accepted it before, but the emitted output was rewriting only the first class in the list. All members of a grouped use are now expanded correctly into the import surface of the emitted module.

Further reading: the PHPUnit conversion exercises all three of these surfaces densely. The full list is on the open source showcase.