Dne 29.06.2026 v 15:00 Michal Kral napsal(a):
Hi all,
I have a complete, tested implementation of scalar object methods —
$str->trim(), (3)->pow(2) — and I'd like to write it up as a formal RFC.
Could someone grant me wiki RFC karma? The design and the branches are
below; I'd genuinely value early reactions while I write it up —
especially from anyone who remembers why the 2014 attempt stalled, since
this is built specifically to resolve that.
I know "methods on primitives" was proposed and declined before
(Nikita's 2014 "Methods on primitive types in PHP"). The reason it
stalled was loose typing: $x->trim() would need a runtime type check and
would behave differently depending on what $x held. This proposal
sidesteps that entirely, by generalizing the resolution Nikita himself
suggested in that thread — requiring an explicit cast where the type
isn't already clear.
The idea: dispatch only on receivers the compiler already knows are
scalar. The method call is rewritten at compile time to an ordinary call
into an internal backing class — no runtime type dispatch, no new
opcode, the object method-call path is untouched. A receiver qualifies
only if its type is guaranteed syntactically: a literal, a (string)/
(int) cast, a concatenation/interpolation, a non-nullable scalar-typed
property, or a call with a declared non-nullable scalar return type. An
untyped $x->trim() is left exactly as today (Error). Crucially, dispatch
never depends on optimizer-inferred types, so behaviour is identical
with and without opcache.
echo " Hello World "->trim()->upper(); // "HELLO WORLD"
echo (3)->pow(2); // 9
echo "hello"->length()->pow(2); // 25 — length():int chains
into the int methods
So the cast Nikita proposed (((string) $num)->chunk()) is only needed
where the type isn't already guaranteed; everywhere else the dispatch is
sound by construction, with no runtime check.
It's structured as one proposal with two independent votes:
Scalar methods on guaranteed free receivers (the above). A pure
capability — it adds a way to call scalar operations and changes nothing
about untyped code. Proposed initial sets: a small curated Str (trim/
upper/lower/length + contains/startsWith/endsWith), Int (abs/pow/clamp),
and Float (round/ceil/floor/abs); bool deliberately gets none (its
operations are operators, not methods). The sets are governed by
explicit criteria and are the easiest thing to tune in discussion.
Scalar-typed local variables (int $x = ...;, scalar types only), which
additionally make a typed local a guaranteed receiver (string $s = ...;
$s->trim()). This is the more contested half — it also carries the
"local type discipline" argument — so it's a separate vote: a "no" here
ships the capability without typed locals.
What I'm deliberately NOT doing, up front so it's not a surprise:
No method-call-result receivers ($this->getName()->trim()) — that would
rest on return-type covariance under inheritance; not worth the surface.
Int::abs/pow return int|float (they can overflow, as the global
functions do), so they're honest terminals — they don't chain.
(Int::clamp is the one initial int method provably : int for all inputs,
so it does chain — no method declares : int while secretly overflowing.)
No int|false typed locals — that's a sentinel state, not a committed
type; ?T is supported, sentinel-unions are not.
The backing classes are internal-only (NUL-prefixed name, like anonymous
classes): class_exists('Str') is false, no Reflection, userland class
Str {} can't collide.
Implementation status — this is built and tested, not a sketch:
Scalar methods add zero new opcodes — the desugar emits an ordinary
static call, and the object method-call path is byte-for-byte unchanged.
(Typed locals do add dedicated *_TYPED assignment opcodes, but the
untyped hot path stays byte-identical — see the perf point below.)
Performance (deterministic callgrind, release build): the untyped hot
path is byte-identical; the standard bench.php suite is +0.145%
instructions, entirely from predicted-not-taken branches in reference
opcodes only, with zero added cache misses or branch mispredictions.
Untyped code pays effectively nothing.
References (the objection that sank prior typed-locals attempts) are
enforced through every path — =&, by-ref params, array/object/static-
prop refs, yield, closure capture, $$name, extract, $GLOBALS, global —
via the existing typed-property reference machinery. Leak-checked under
stress.
Correct under JIT in all three modes (interpreter, function, tracing —
differential byte-identical output). opcache SHM + file_cache round-trip
verified.
BC impact, measured: an AST scan of the 1,000 most-downloaded Packagist
packages (173k+ files, incl. Laravel, Symfony, the AWS SDK, Guzzle,
PHPUnit, Doctrine) found zero affected call sites — every guaranteed-
scalar method-call site is a fatal error today, so none exist in real
code. Userland Str classes (incl. Laravel's Illuminate\Support\Str)
coexist with the backing class, verified.
Branches (PHP 8.6-dev base):
Primary (scalar methods): https://github.com/kralmichal/php-src/tree/
rfc/scalar-methods
Secondary (typed locals, stacked): https://github.com/kralmichal/php-
src/tree/rfc/typed-locals
What I'm asking:
RFC karma, so I can write this up on the wiki.
Input I'd value while I write it up: does the "compile-time-guaranteed
receivers only" framing actually resolve the loose-typing objection for
you, or is there a hole I'm not seeing?
The method-set/naming is the most open part — is a small curated set (a
"clean slate" API, distinct from the procedural names) the right call,
or a non-starter?
(I'm not asking you to pre-approve the idea — I'll put the full RFC on
the wiki and we can have the real discussion there. This is to get karma
and to catch any fatal objection before I do.)
Thanks for reading, Michal Kral
Sorry forgot wiki username: michalkral