Types
Data types supported by elephc: int, float, string, bool, array, null, mixed, iterable, resource, callable, object, enum, and extension types.
Data Types
| Type | Supported | Notes |
|---|---|---|
int | Yes | 64-bit signed integer. Literals: decimal 42 / 1_000_000, hexadecimal 0xFF / 0xFF_FF, legacy octal 0755 / 0_755, explicit octal 0o755 / 0O755 (PHP 8.1+), binary 0b1010 / 0B1010 (PHP 5.4+). Numeric separators _ allowed between digits in any base (PHP 7.4+). |
string | Yes | Pointer + length pair, double and single quoted |
null | Yes | Sentinel value, coerces to 0/"" in operations |
bool | Yes | true/false as distinct type. echo false prints nothing, echo true prints 1. Coerces to 0/1 in arithmetic. |
float | Yes | 64-bit double-precision. Literals: 3.14, .5, 1.5e3, 1.0e-5, 1_000.5, 1e1_0. Constants: INF, NAN. |
array | Yes | Indexed ([1, 2, 3]) and associative (["key" => "value"]). Heterogeneous indexed and associative payloads widen to boxed mixed. Arrays use copy-on-write semantics. |
array<T> | Yes (extension) | Pins the element type so it keeps register-width storage instead of widening to boxed mixed. An elephc extension rejected by --strict-php; see Generics. |
array<K, V> | Yes (extension) | The associative form: hash storage with both halves typed. K must be int, string, or mixed. Not interchangeable with array<T>, which is a packed element vector. Also rejected by --strict-php. |
Box<T> | Yes (extension) | A generic class or interface named with type arguments. Instantiated into one ordinary class per distinct argument list, so Box<int> holds a machine integer where Box<string> holds a string. An elephc extension rejected by --strict-php; see Generics. |
mixed | Yes | Supported in type hints and typed locals. Runtime values are boxed with a per-value tag, including heterogeneous array payloads and union storage. |
iterable | Yes | PHP pseudo-type for array | Traversable. Supports indexed arrays, associative arrays, Iterator, and IteratorAggregate; runtime operations (foreach, echo, gettype(), var_dump(), ===, casts, is_iterable()) dispatch on heap-kind, value-type, or interface metadata as needed. |
resource | Inferred only | File handles and standard streams are modeled separately from integers. fopen() returns resource|false, and STDIN, STDOUT, and STDERR are stream resources. PHP does not allow resource as a type declaration, so elephc does not accept resource annotations. |
callable | Yes | Closures, arrow functions, first-class callables, and FFI callback parameters. |
object | Yes | Class instances. Heap-allocated, fixed-layout. new ClassName(...); the generic object type hint accepts any class, interface, or enum object. |
enum | Yes | Pure and backed enums. Cases are singletons. Backed enums support ->value, ::from(), ::tryFrom(), ::cases(). |
| `int | string` | Yes |
?int | Yes | Nullable shorthand — sugar for `int |
| `string | null` | Yes |
| `int | false` | Yes |
void | Return only | Valid as a function, method, closure, arrow, or extern return type. Internally, null is represented as Void. |
never | Return only | Marks a function, method, closure, or interface method that never returns — it must always throw, call exit()/die(), or loop forever. Returning is rejected at type-check time. |
ptr / ptr<T> | elephc extension | Raw 64-bit pointer, optionally carrying a checked compile-time pointee tag. See Pointers. |
buffer<T> | elephc extension | Fixed-size contiguous storage for POD scalars, C widths (buffer<u8>, buffer<f32>), pointers, or packed records. See Buffers. |
packed class | elephc extension | Flat POD record type with compile-time field offsets. See Packed Classes. |
Integer-form numeric literals keep the int type only while they fit in PHP’s signed 64-bit range. Larger decimal, hexadecimal, octal, or binary literals are promoted to float, matching PHP on 64-bit builds.
Literal pseudo-types in unions
PHP lets null, false, and true appear as type members. elephc accepts them in parameter and return positions, and accepts null and false as property types, matched case-insensitively like the other built-in type names; a property type containing true is rejected (see Literal property types).
<?php
function find(string $haystack, string $needle): int|false {
return strpos($haystack, $needle); // a real int, or the literal false
}
function label(?string $name): string|null {
return $name === '' ? null : $name;
}
class Setting {
public string|null $value = null;
}
Rules:
T|nullis exactly equivalent to the nullable shorthand?T— both compile to the same type, sostring|nulland?stringare interchangeable.falseremains a distinct subtype in declarations such asint|false, andboolaccepts it. A union that also containsboolabsorbs the redundantfalsemember. Literaltruetypes usebool; the runtime representation offalseis the ordinary boolean representation.- A multi-member union may mix these with other members (
int|string|null); thenullmember keeps the whole union nullable. - The nullable shorthand still may not be combined with a pipe union: write
T|null, not?T|U.
Intersection types
An intersection type A&B (PHP 8.1) declares a value that satisfies every listed class/interface. elephc accepts the syntax in parameter and return positions:
<?php
function render(Renderable&Cacheable $widget): string {
return $widget->render();
}
The & is recognized as an intersection only when it is followed by another type; a & before a variable (int &$x) remains the by-reference marker. The nullable shorthand cannot be combined with an intersection: ?A&B is rejected, matching PHP.
Current limitation: the value is typed as its first listed member, so member access resolves against that member ($widget->render() above, from Renderable). Methods declared only on later members are not yet resolved, and argument compatibility is checked against the first member. Full structural intersection resolution is planned.
The internal PhpType model also includes TaggedScalar, which is not PHP
syntax and cannot be written in source code. Codegen uses it only for the
default tagged null representation of int|null values, storing an inline
{payload, tag} pair instead of a heap-boxed mixed cell.
Never
never marks a function, method, closure, or interface method that must not return normally. The function body is expected to either throw, call exit()/die(), or loop forever.
<?php
function panic(string $msg): never {
throw new RuntimeException($msg);
}
class Failer {
public function fail(): never {
throw new \Exception("boom");
}
public static function bail(int $code): never {
exit($code);
}
}
interface Aborts {
public function abort(): never;
}
Rules:
- valid as a return type for functions, closures, instance methods, static methods, and interface methods
- matched case-insensitively like PHP’s built-in type names (
never,Never, andNEVERare equivalent) - must be used as a standalone return type;
?never,never|null, andint|neverare rejected - not valid as a parameter, property, or typed local
- declaring
: neverand then writingreturn $value;(or even barereturn;) is rejected at type-check time : neveris the bottom type in the type system: it is a subtype of every other type, so a child method may override a parent that returnsvoid/int/etc. with: never- the reverse is rejected: a parent or interface method declared as
: neverrequires the child/implementation to declare a compatible return type - if execution falls through a
: neverfunction or method body, elephc emits a runtime fatal error instead of returning to the caller
Typed local declarations
<?php
int|string $value = 1;
?int $maybe = null;
Rules:
- union types are supported in typed local declarations, for example
int|string - nullable shorthand
?Tis supported as sugar forT|null - at runtime these values are lowered to the compiler’s boxed tagged representation
?T|Uis not accepted; writeT|U|nullexplicitly instead- method calls and property access work on object unions — a single object class plus scalars (
A|false,A|null) and unions of two or more distinct object classes (A|B,A|B|false). The method/property must exist on every object member; codegen dispatches on the runtime class id, and a non-object runtime value faults like PHP
Property type declarations
<?php
class User {
public int $id;
public string $name = "Ada";
public ?string $email = null;
public static int $count = 0;
}
Rules:
- instance and static properties can use declared property types
- property defaults and assignments must be compatible with the declared type
- constructor assignments through untyped parameters are checked once call sites refine the parameter type
- nullable and union property storage is boxed using the same mixed runtime shape as typed locals; scalar literal defaults (
int|string $v = 1,float|int $v = 1.5,bool|int $v = true) are boxed into that shape - static property redeclarations across inheritance follow PHP-style rules: non-private inherited properties keep invariant declared types, cannot reduce visibility, and cannot override
finalproperties - private inherited static properties can be redeclared as independent subclass slots
- untyped inherited static properties cannot be redeclared with a type, and typed inherited static properties cannot be redeclared without one
- direct element writes to static array properties, such as
ClassName::$items[] = $valueorClassName::$items[0] = $value, require the property to be anarray voidandcallableare not valid property types
Null behavior
<?php
$x = null;
echo $x; // prints nothing
echo is_null($x); // prints 1
echo $x + 5; // prints 5 (null → 0)
echo $x . "hello"; // prints "hello" (null → "")
$x = 42; // reassignment from null works
Type Casting
$i = (int)3.7; // 3
$f = (float)42; // 42.0
$s = (string)42; // "42"
$b = (bool)0; // false
$a = (array)42; // [42]
$l = (array)[1, 2]; // [1, 2] (the same array, unchanged)
$n = (array)null; // []
$o = (array)$obj; // property name => value hash, with PHP's visibility-mangled keys
$m = (array)$mixed; // dispatches on the runtime tag: arrays pass through, scalars wrap, objects project
$p = (object)['k' => 1]; // stdClass with a `k` property
$q = (object)42; // stdClass with a `scalar` property
(object) converts an array to a stdClass whose property names are the
array’s keys rendered as strings (so (object)['x', 'y'] has the properties
0 and 1, reachable as ->{'0'}), null to an empty stdClass, and every
other non-object value to a stdClass carrying it in a single scalar
property. An object source is returned unchanged — (object)$obj === $obj
— rather than copied.
(array) returns an array unchanged, turns null into an empty array,
and wraps any scalar (int, float, bool, string) as a one-element list
[0 => value]. The static type follows the source: (array)"x" is a list of
strings, (array)$list keeps the list’s own type, and a runtime-typed source
(mixed, a union such as int|string or ?array) is typed as PHP array.
(array) on an object projects all of its properties into a string-keyed hash
using PHP’s exact key mangling — x for a public property, "\0*\0y" for a
protected one, "\0Class\0z" for a private one — including the
__PHP_Incomplete_Class payload a restricted unserialize() produces. For the
scope-aware, unmangled view use get_object_vars(). (array) on a boxed
mixed value dispatches on the runtime tag at run time.
Cast names and aliases are case-insensitive, matching PHP. For example,
(INT), (Integer), and (integer) are equivalent.
(int) and intval() on a float follow PHP’s zend_dval_to_lval rules on every supported
target: NAN and ±INF become 0, an in-range value truncates toward zero, and any other
out-of-range value is reduced modulo 2^64 before being read back as a signed 64-bit integer
(so (int)1e300 is 0 and (int)1.5e19 is -3446744073709551616). The same conversion is
used for float array keys, so $a[NAN] and $a[INF] both write index 0.
Aliases: (integer), (double), (real), (boolean).
Type functions
| Function | Signature | Description |
|---|---|---|
is_null() | is_null($val): bool | Returns true if null |
is_float() | is_float($val): bool | Returns true if float |
is_int() | is_int($val): bool | Returns true if integer |
is_string() | is_string($val): bool | Returns true if string |
is_numeric() | is_numeric($val): bool | Returns true if int or float |
is_bool() | is_bool($val): bool | Returns true if bool |
is_array() | is_array($val): bool | Returns true if indexed or associative array |
is_object() | is_object($val): bool | Returns true if value is an object |
is_scalar() | is_scalar($val): bool | Returns true for int, float, string, or bool (not null, array, object, or resource) |
is_iterable() | is_iterable($val): bool | Returns true if array or Traversable-compatible iterable |
is_countable() | is_countable($val): bool | Returns true for an array or an object whose class implements Countable; a statically typed object that cannot decide it is checked against its runtime class |
is_callable() | is_callable($val): bool | Returns true for closures, first-class callables, strings case-insensitively naming known builtins, user functions, or public static methods ("Class::method"), [$obj, "method"] arrays with public methods, [ClassName::class, "method"] static method arrays, and objects with public __invoke(). |
is_resource() | is_resource($val): bool | Returns true if value is an open resource handle |
is_nan() | is_nan($val): bool | Returns true if NAN |
is_finite() | is_finite($val): bool | Returns true if not INF/NAN |
is_infinite() | is_infinite($val): bool | Returns true if INF or -INF |
boolval() | boolval($val): bool | Convert to bool |
floatval() | floatval($val): float | Convert to float |
intval() | intval($value, $base = 10): int | Converts to integer. $base applies only to a string $value; base 0 auto-detects 0x/0b/a leading octal zero |
strval() | strval($val): string | Convert to string |
gettype() | gettype($val): string | Returns type name |
empty() | empty($val): bool | Returns true if value is falsy |
unset() | unset($var, ...$vars): void | Unbinds one or more variables. On an eligible local a later read is a compile error, in both modes — see Local retyping |
settype() | settype($var, $type): bool | Changes variable type in place |
PHP’s predicate aliases are supported and behave identically to their canonical
forms: is_integer() and is_long() are aliases of is_int(), and
is_double() and is_real() are aliases of is_float().
is_countable() is the guard to use before count() on a value that may not be
countable. It is decided at compile time when the declared type settles it, and
against the runtime class otherwise, so a Countable subclass behind a base-class
parameter still answers true:
function size(mixed $value): int {
return is_countable($value) ? count($value) : -1;
}
echo size([1, 2, 3]); // 3
echo size(new ArrayObject(["a", "b"])); // 2
echo size("text"); // -1
Type narrowing
Inside an if (or if/elseif*/else chain) guarded by a type predicate on a variable, that variable is narrowed to the tested type within the matching branch(es), so it can be used as that type without an explicit cast. is_int(), is_float(), is_string(), and is_bool() (and their aliases) narrow to the matching scalar, and $x instanceof SomeClass narrows to that class — including calling its methods. Each subsequent elseif, and the else branch, see the complement of all previous guards. The statements after the whole construct also see the complement when the chain is exhaustive by divergence — there is no else and every clause body always diverges (return, throw, exit(), die(), or a call to a : never function) — because reaching them means every guard was false. A leading ! flips the then/else branches.
function describe($x): string { // $x may be int or a Point across call sites
if (is_int($x)) {
return "int " . ($x + 1); // $x is int here
}
return "point " . $x->label(); // $x is the object here
}
Compound && and || guards combine facts according to short-circuit evaluation. An && body sees both operands as true; after a throwing || guard, both operands must have been false. The right operand also sees the facts established by the left operand on the path that evaluates it:
function fallback(?string $raw, ?int $default = null): int {
if ($raw === null && $default !== null) { return $default; }
return 0;
}
Assignments such as $options = $options ?? new Options() and $options ??= new Options() establish a non-null local receiver when the default excludes null. This also holds inside a branch after the assignment completes. A nullable default keeps the nullable type when the current value may be null. After a retained non-null coalescing fact, another coalescing assignment keeps that fact and skips its default, even when the default is nullable. The skipped expression is still checked for diagnostics. An unset invalidates the fact, including inside a conditional branch.
A nullable local must also be bound on every path reaching its use before its read can exclude null. For example, if ($choose) { $value = $parameter; $value ??= 5; } establishes an integer inside that branch, but the following read or closure capture remains nullable because the branch may be skipped. Joins account for loop entry, switch alternatives and exception paths, including an exception before the first assignment. A completed assignment in finally can establish the binding for the continuation. These presence checks preserve the existing handling of raw scalar locals and do not enable strict undefined-variable diagnostics globally.
Existing locals assigned inside nullsafe arguments or dynamic member names keep the skipped and evaluated alternatives in following reads. The assignment runs only when the receiver is non-null.
An instanceof check against a second interface grants that interface inside the guarded branch. After the branch, the receiver keeps its original interface contract unless an assignment changes it. A receiver whose concrete subclass is already known keeps that subclass when probing a parent class or an implemented interface.
When a compound guard leaves several alternatives for an original mixed value, the read remains mixed. For example, is_int($value) || is_string($value) preserves existing weak conversions at parameters, returns and properties, including values copied into locals or captured by closures. The same applies to nullable alternatives and objects implementing Stringable. A single scalar guard still narrows: is_float($value) requires an explicit (int) cast at an integer parameter because a float conversion can lose precision. Declared union contracts and by-reference storage rules are unchanged. See Parameter type coercion.
A narrowed read keeps the variable’s original storage representation. A nullable or union scalar cannot be passed to a concrete scalar parameter by reference when that parameter requires raw scalar storage. Declare the reference parameter nullable or mixed to match the variable’s storage. Coalescing does not establish a persistent non-null fact for reference-aliased, global or static bindings. Inside a loop, a retained non-null fact must also hold on later iterations: a nullable write on a continuing path restores the nullable view at loop entry. Writes followed by break or return affect their exit path without changing a reachable guard complement in the loop body.
After a nullable integer receives a non-null fallback, ordinary reads and by-value closure captures use the integer value while reference calls keep the nullable storage. A complete if ($value === null) { $value = 40; } fallback also establishes an integer read. A partial assignment or a later reference call does not retain that proof. Integer reads require a stable, definitely bound local: reference aliases and incoming reference parameters keep their nullable representation so writes through another alias remain visible. A by-value capture without that proof preserves the actual value, including null. A declared int return checks that snapshot when the closure is invoked. An unset on a continuing loop path also removes the proof for reads on later iterations; a fallback inside the body can establish it again.
An incoming ?int &$value reference keeps the caller’s tagged integer/null storage. A guarded ++ or -- works while its result fits in an integer; overflow raises an explicit runtime storage error. PHP would promote that result to float, which this reference representation cannot hold. Use mixed &$value when an update must support float promotion.
Narrowing is not tracked across a reassignment of the variable inside the branch. The assignment itself is checked against the variable’s own type, the one it had before the guard, so the standard fallback idiom compiles even though the narrowed view and the assigned value have nothing in common:
$files = glob($dir . "/*.meta"); // string[]|false
if ($files === false) { // $files reads as false inside the branch
$files = []; // still fine: string[]|false holds an empty array
}
echo count($files);
The same applies on the else side (if ($files !== false) { … } else { $files = []; }) and inside a while whose condition narrows. A value the variable’s own type cannot hold either — $files = new stdClass() above — is still rejected, because re-binding a local to an unrelated type is only allowed in straight-line code; see Local retyping. Once the branch has assigned the variable once, later assignments in the same branch are checked against what that first one stored.
Narrowing applies to function and method parameters. A parameter whose call sites pass incompatible types (e.g. int at one site and a class instance at another) is inferred as a union, and the guard narrows it inside each branch. This is not yet supported for closure parameters: a closure invoked with incompatible argument types is rejected at compile time rather than inferred as a union.
A null argument is the exception, and never fixes a closure parameter’s type. It carries no storage the parameter could adopt — a parameter closed to null alone could satisfy no other call — so $f(null) leaves the parameter open exactly as omitting the argument does, and both spellings of the call accept anything afterwards:
$f = function ($v = null) { var_dump($v); };
$f(null); // NULL
$f(5); // int(5)
Local retyping
A local is monomorphic by default, but three shapes let it change type anyway. unset($a) ends the binding, and the next assignment re-binds $a at any type with no diagnostic. A plain straight-line reassignment ($a = 0; $a = "ciao";) re-binds $a to a fresh slot of the new type and warns. A branch-divergent assignment (if (…) { $a = 0; } else { $a = "ciao"; }) compiles the local as boxed mixed storage for the whole body and warns — a performance signal as much as a correctness one, since every read of it then goes through the box.
A for initializer is straight-line code too: it runs exactly once whenever the loop statement does, so for ($i = 0; …) { … } $i = "done"; re-binds $i with the same warning as $i = 0; for (; …) { … } $i = "done";.
The same straight-line re-bind also happens silently, with no warning, when a numeric local is reassigned a different numeric type, so a counter name reused by a later loop gets a slot of its own in each loop:
for ($i = 0; $i < $n; $i++) { … } // $i stays a raw integer counter
for ($i = $n - 1; $i >= 0; $i--) { … } // $n - 1 may overflow to float: this $i is boxed
Nothing PHP can observe changes (the old value is dropped either way, and each loop still promotes to float exactly where PHP does); only the second loop pays for the boxed storage its own initializer needs.
One behaviour differs from PHP: reading a variable after unset() is a compile error, where PHP warns and evaluates the read as null. Probe the name with isset() (or empty() / ??), which stay legal on an unbound name:
$a = "x";
unset($a);
echo $a; // Undefined variable: $a — compile error
echo isset($a) ? "set" : "unset"; // fine: prints "unset"
A type-hinted parameter is retyped by the same three shapes. PHP’s parameter type
constrains the incoming argument, not the local afterwards, so
function format(float $v): string { $v = (string) $v; return $v; } compiles with the ordinary
retype warning. The hint still governs the call boundary; only the local’s later type is free.
None of this touches a declared type: a typed local (int $x = 1;) and a class property stay strict in every mode. --strict-locals turns the two warning shapes back into hard errors; see Strict locals mode for the flag and for which names are eligible.
Parameter type coercion
PHP’s default (coercive) mode converts a scalar argument to a declared scalar parameter type when the call runs. elephc applies the same conversions, but only where it can reproduce PHP’s result exactly. Everything in this section describes a file without declare(strict_types=1); see Strict types for what the directive changes.
Always accepted. These conversions are total (every value converts), produce no PHP diagnostic, and use the same code as the corresponding explicit cast, so they match PHP byte for byte for both literals and runtime values:
| Declared parameter | Accepts | Example |
|---|---|---|
string | int, float, bool | f(42) → "42", f(4.5) → "4.5", f(false) → "" |
bool | int, float, string | f("0") → false, f(-0.5) → true |
float | int, bool | f(5) → 5.0 |
int | bool | f(true) → 1 |
Accepted for compile-time constants. A float or numeric-string literal binds to an int or float parameter when PHP’s conversion is exact:
function takesInt(int $i) { return $i; }
function takesFloat(float $f) { return $f; }
echo takesInt(5.0); // 5
echo takesInt("42"); // 42
echo takesInt(" 42 "); // 42 — PHP allows surrounding whitespace
echo takesFloat("1e3"); // 1000
Rejected at compile time. Every remaining float/string → int and string → float binding is a compile error naming the PHP behaviour, because PHP decides those cases at run time with a channel elephc does not have at a parameter boundary:
- A lossy conversion. PHP emits
Deprecated: Implicit conversion from float 5.5 to int loses precisionand passes5; elephc has no runtime deprecation channel (the same gap documented for++/--below), so it refuses rather than dropping the notice. - A non-numeric or partially numeric string. PHP throws
TypeError— note this differs from the(int)cast, where(int)"42abc"is42. - A runtime-valued
floatorstring. Which of the three PHP outcomes applies is only knowable at run time.
Add the explicit cast the call site means — takesInt((int) $f), takesFloat((float) $s) — or intval()/floatval().
Not covered. Coercive binding applies to by-value declared parameters of user-declared functions, methods, static methods and constructors:
- Pass-by-reference parameters (
function f(string &$s)) stay strict. PHP converts the caller’s variable in place and writes the converted value back; elephc would have to pass a converted temporary, silently dropping the callee’s writes, so the call is rejected instead. - Builtin functions keep their own per-builtin argument rules.
- Classes injected by the compiler (SPL,
Exception, reflection) stay strict, because several of their members are lowered by dedicated emitters rather than the shared argument path. - Nullable and union parameters (
?string $s,int|string $v) stay strict; only a plain declared scalar type binds coercively.
Strict types
A file whose leading declare sequence enables strict_types=1 gets PHP’s strict parameter binding: a declared scalar parameter accepts only an argument of exactly that type, plus the single widening of int into a declared float. Every conversion in the tables above becomes a compile error naming the TypeError PHP would throw at run time.
The directive may follow earlier top-level declare statements, including a bare
single-statement body such as declare(ticks=1) echo 0;. An ordinary statement
between declarations, or a strict directive nested inside a body, is rejected.
The main script uses its final strict flag even for an earlier declare body;
functions, methods and closures defined before the directive retain their original
binding mode. A later declare(strict_types=0) does not clear an earlier 1.
| Argument type → | int | float | string | bool |
|---|---|---|---|---|
declared int | accepted | rejected | rejected | rejected |
declared float | accepted (widened) | accepted | rejected | rejected |
declared string | rejected | rejected | accepted | rejected |
declared bool | rejected | rejected | rejected | accepted |
<?php
declare(strict_types=1);
function takesInt(int $i) { return $i; }
function takesFloat(float $f) { return $f; }
echo takesFloat(42); // 42 — the one implicit conversion strict mode keeps
echo takesInt((int) "7"); // 7 — an explicit cast is always accepted
echo takesInt(true); // compile error: must be of type int, bool given
echo takesInt("42"); // compile error: must be of type int, string given
The directive is scoped to the physical file it appears in, exactly as in PHP. It does not propagate into included files, and the file containing the call site decides — not the file declaring the callee:
// lib.php (no declare)
function coerceHere(int $i) { return $i; }
function fromLooseFile() { return coerceHere(true); } // still coerces to 1
// main.php
declare(strict_types=1);
require __DIR__ . '/lib.php';
echo fromLooseFile(); // 1 — the call above lives in a coercive file
echo coerceHere(true); // compile error — this call lives in a strict file
What the directive covers. Declared by-value parameters of user functions, methods, static methods, constructors, closures, arrow functions, first-class callables, declared variadic element types, and call_user_func/call_user_func_array — which forward the caller’s frame in PHP and therefore stay strict.
What it does not cover.
- Callbacks invoked by other internal functions (
array_map,usort,array_walk,preg_replace_callback, …) keep coercive binding, matching PHP: the engine calls them from its own frame, which never carries the directive.array_map('g', [true])still passes1tog(int $i)in a strict file. - Builtin function arguments keep their own per-builtin rules; the directive does not tighten them. PHP throws
TypeErrorforstrlen(42)understrict_types=1while elephc still applies each builtin’s own argument checking. - Return types, typed property assignments and typed constants are unaffected; PHP also applies
strict_typesto those. - Positional spread arguments (
f(...$args)) and variadic parameters of an inline closure literal (function (int ...$xs) {}) are not checked against the declared parameter type, in strict or coercive mode. - Values whose type is only known at run time (
mixed, union-typed and array-element values) are not rejected: elephc cannot raise PHP’s runtimeTypeErrorat a parameter boundary, so such a binding is left to the existing compatibility rules. The exception is an object union in the exact shapeClass|falseorClass|nullpassed to aClassparameter of a user-declared method or constructor: elephc accepts it and inserts a runtimeinstanceofguard that throwsTypeErrorfor the other alternative.
Callable strings
PHP accepts a function-name string wherever a callable is declared and resolves it when the call runs. elephc resolves callables statically, so a callable string binds when it is a compile-time constant:
function apply(callable $f, string $s) { return $f($s); }
echo apply("strtoupper", "abc"); // ABC — builtin name
echo apply("my_helper", "abc"); // — user function name
echo apply("Formatter::wrap", "abc"); // — "Class::method"
Names are matched case-insensitively and a leading \ is allowed, matching PHP. A callable string that is only known at run time is rejected with a callable string must be a compile-time constant here; pass a first-class callable (strtoupper(...)), a closure, or a literal name instead. A constant string that names nothing is rejected too, where PHP throws TypeError when the call runs.
Two gaps remain: array callables ([$obj, "method"], ["Class", "method"]) are still rejected at a callable parameter, and because elephc maps the Closure type hint onto the same internal callable type, a Closure parameter also accepts a callable string where PHP requires an actual Closure.
Known incompatibilities with PHP
- A guarded
++or--on an incoming?int &$valuereference raises a runtime storage error on integer overflow, where PHP promotes the value to float. Usemixed &$valuewhen the reference must support float promotion. $argv[0]returns the compiled binary path, not the.phpfile path.- Dynamic property names accept statically typed strings, integers, and
mixedvalues. A statically typedStringableobject is still rejected rather than converted through__toString()as PHP does. - A runtime-name property lookup that misses declared object slots can return
nullwithout PHP’sUndefined propertywarning. This separate diagnostic gap also applies when a later write correctly throws anErrorfor that null result. - Integer
+,-, and*overflow promotes tofloatfor both constant-folded and runtime arithmetic, matching PHP on 64-bit builds.intval()/(int)of an integer-valued string near the 64-bit boundary (e.g.intval("9223372036854775807")) is still lossy at the string-conversion boundary. - The
**operator is int-preserving like PHP (2 ** 3isint(8)), but thepow()function still always returns afloat(pow(2, 3)isfloat(8)where PHP givesint(8)). A numeric string operand to**is always computed on the floating-point path, so an integer-form string stays afloat("2" ** 3isfloat(8), PHP givesint(8)); the printed value still matches. The other arithmetic operators (+,-,*,/,%) coerce numeric-string operands with PHP’s int-or-float rules — see the numeric-string arithmetic note below. - Arithmetic (
+ - * / % **) accepts numeric and leading-numeric string operands and coerces them at runtime with PHP’s numeric-string grammar: an integer-form string coerces toint("123" + 3isint(126)), a float-form string (containing a.or exponent) tofloat("1.5" + 3isfloat(4.5)), an integer-form string outside the 64-bit range tofloat("99999999999999999999" + 1isfloat(1.0E+20)), and a leading-numeric string uses its numeric prefix (" +12foo" + 3isint(15)). Three gaps remain. A fully non-numeric string with no numeric prefix ("hi") is aTypeErrorin PHP; elephc reports it as a compile error when the operand is a constant string literal ("hi" + 1), but a non-numeric string whose value is only known at runtime is coerced to0rather than raising PHP’s runtimeTypeError— so a program that catches thatTypeErrordoes not compile. Likewise theWarning: A non-numeric value encounteredPHP emits for a leading-numeric operand is reported at compile time for constant literals only, not for runtime string values, and%on a float-form string does not emit PHP’sDeprecated: Implicit conversion from float-string ... loses precision("1.9" % 2isint(1)in both). Relational comparisons between two runtime string operands follow PHP numeric-string and byte-wise ordering rules; string-versus-number comparisons remain unsupported. - The numeric-string coercion is scoped to the binary arithmetic operators. Unary negation still rejects a
stringoperand at compile time (-"5"is a compile error where PHP givesint(-5)), and so do the bitwise operators ("6" << 1,&,|,^,>>), which PHP 8 coerces under the same “Saner string to number conversions” rules. - Converting an array to a string (via
.concatenation,echo, or string interpolation) yields the literal"Array", matching PHP’s value, but elephc does not emit PHP’sE_WARNING“Array to string conversion”. - Scalar loose comparison (
==,!=) follows PHP-style bool truthiness, null-vs-empty-string, numeric-string, non-numeric string byte-comparison, and numericint-vs-floatrules for constant-folded literals and non-folded runtime scalar operands. One known gap: when an untyped (mixed) operand holds afloatat runtime — e.g.switch ($x)over an untyped$x = 1.5, or$x == 1— the value is truncated tointbefore comparing, so1.5wrongly compares equal to1. Statically-typedfloatoperands compare correctly; only untyped float-bearing values are affected. ??=is checked against typed assignment storage for variables, object properties, static properties, and non-append array elements. For concrete local variable types, the fallback must keep the same type or be a literalnull.- Plain array numeric casts (
(int)$array,(float)$array) follow elephc’s existing array cast semantics (return the element count rather than PHP’s0/1). Directiterablenumeric casts use PHP’s empty/non-empty0/1semantics. __destructruns when an object’s refcount reaches zero (scope exit, reassignment,unset, program end), matching PHP’s timing, but object resurrection is not supported: re-storing$thisso the object would outlive the destructor does not keep it alive — the object is still freed once__destructreturns.- Under the compatibility
--null-repr=sentinelopt-out, the integer9223372036854775806(PHP_INT_MAX - 1) collides with elephc’s internal null marker in unboxed scalar slots and is misread asnullbyecho,var_dump(),is_null(),??, and related null checks. The default tagged null representation does not have this collision: the full 64-bit integer range round-trips. - Inside a namespace that declares its own constant named after a predefined one (
const NAN = …;,const PHP_EOL = …;), an unqualifiedNAN/PHP_EOLstill reads the global constant, where PHP reads the namespaced one first. Read the namespaced constant throughconstant(__NAMESPACE__ . '\NAN'), or give it a name no predefined constant uses (#1349). - A
mixedor union bound passed torandom_int(),mt_rand()orrand()is coerced like(int): an int passes through, a float truncates, a numeric string parses. A non-numeric string becomes0, where PHP throws aTypeErrorfor theintparameter. - A
SplDoublyLinkedList,SplStackorSplQueueoffset ofnullreads as index0, as in PHP, but without PHP’sPassing null to parameter #1 ($index) of type int is deprecated.SplFixedArrayoffsets follow PHP for an int, bool, float (with PHP’sImplicit conversion from float … to int loses precisiondeprecation) or canonical integer string ("1"), and any other offset raisesCannot access offset of type <type> on SplFixedArray— including a resource offset, where PHP instead warnsResource ID#N used as offset, casting to integer (N)and indexes by the handle. - Invoking an object (
$this(...),($this)(...),$obj(...)) is checked against the static class: the class must declare or inherit__invoke. An abstract parent calling$this()when only a subclass defines__invokeis refused at compile time, where PHP resolves the method at run time. usort()callbacks that do not define a consistent order always terminate and keep every element, but the resulting order can differ from PHP’s.sort()andrsort()over raw float and boxed scalar arrays follow PHP’s comparison schedule, includingNANplacement.- Variable variables (
$$name,${$expr}) are not supported yet. Native AOT locals use fixed compile-time stack slots; supporting a runtime-computed name will require routing the access through Magician’s materialized named scope and synchronizing the affected native locals. Use an array keyed by the dynamic name in portable elephc code for now. array_push()rejects nested receivers rooted in function, method or closure parameters, and boxed local copies of untyped parameters. Those incoming containers use boxed storage whose nested reference lowering is not yet supported; the compiler reportsarray_push() nested parameter storage is not supported.array_push()rejects dynamic property receivers, including literal names such as$object->{"items"}and indexed children of those properties, because their reads do not provide writable receiver binding. Use$object->itemsinstead. Nullsafe member access cannot be passed by reference, so a receiver such as$object?->itemsreportsArgument #1 ($array) could not be passed by referenceduring checking.- An associative or declared-
arrayreceiver selected beneath a temporary parent, such asarray_push(makeRows()[0], 2), requires the parent array to be stored in a local variable first. A statically indexed receiver from a temporary parent remains supported, for example whenmakeRows()returnsarray<array<int>>. - Native
array_push()receiver places also accept numeric arithmetic, warning-free casts, POD buffer reads with integer indexes, fresh buffer allocation and integer-buffer factories limited to local computation. Numeric operands must have known numeric types and no external aliases; object/string coercions, globals and unknownmixedvalues do not satisfy this proof. Integer/boolean casts additionally require proven integer operands, including bounded loop arithmetic, because float casts can invoke warning handlers. A factory must take positional integer values without references, use only its parameters and definitely assigned locals, and create, read or write its own integer buffers. Integer intermediates must fit their range. Boundedforloops are allowed when their bodies do not rebind existing locals. Globals, static state, object inputs, callbacks, further function calls, deprecation handlers and reference returns remain outside this proof, so those arguments need live declared-arrayreceiver storage instead. array_push()requires its receiver to be passed directly before any unpacked arguments, for examplearray_push($a, ...$values)orarray_push(array: $a). Supplying the receiver through unpacking, includingarray_push(...["array" => $a]), is rejected because the unpacked entry has separate storage from$a. A named receiver after an unpack is also rejected, even when that unpack is empty. Native receiver places that use copy/write-back, including inferred untyped properties and nested native reference aliases, accept literal/local value trees, the numeric and buffer computations described above, and literal/local indexes. Other computed indexes or values can replace the selected storage during argument evaluation; use a declaredarrayproperty for that live-reference behavior.- A
nullvariable passed to a non-nullable by-reference parameter is rejected at compile time (must be of type int, null given). That is php-src’s own rule for a userlandfunction f(int &$x), which throws aTypeError, and it applies here to elephc-PHP builtins written with a by-reference parameter too. The one place it bites in practice iscurl_multi_exec($mh, $still_running): php.net’s examples often seed$still_running = null, which php-src accepts only becausecurl_multi_exec()is an internal function, where the coercion happens on the way in. Seed it with anint—$still_running = 1;— which is valid in both. The same applies to any other elephc builtin declaredint &$out, and to your own by-reference parameters. array_push()onto a statically typed associative receiver requires each pushed value to fit its existing entry type. For example, astringcannot be pushed into anarray<string, int>, although PHP accepts it. A receiver whose entries already usemixedaccepts heterogeneous values. Generator,Traversable, declared-array, and other runtime-typed spreads require that mixed entry storage because their element layout is not statically available. Values backed by untyped parameters or globals also require mixed entries. A concrete indexed receiver joined to another local by=&, or previously captured by reference, requires plain pushed values with the same element representation in its enclosing body. Mutation of the captured variable itself inside a by-reference closure can widen its boxed storage; a read-only capture keeps its native layout. Nested native indexed receivers rooted in a reference also require matching element representations, including inside a closure. Runtime spreads are rejected for the broader set of reference-aliased indexed receivers even when their inferred elements match. Runtime spreads into inferred native property slots, static locals, or indexed children of globals are rejected until their copy/write-back coordinator is available. Associative static-local receivers are also rejected. Property receivers passed through an untyped parameter require a declared receiver class so their field layout is stable. Use$arr[] = $vto widen a non-aliased inferred receiver; broader explicit reference-group widening remains separate work (issue #1508).- A concrete local that is already a reference alias cannot be passed to a by-reference variadic parameter:
$q = &$p; replace($p);withfunction replace(&...$items)is rejected at compile time (requires a variable with mixed/union/nullable storage when passed by reference). PHP shares one zval, so the callee’s$items[0] = …writes through to both$pand$q; elephc would have to widen the shared cell to boxedmixed, and that is only safe when the cell is boxed as the alias is created rather than at the call, because every earlier read of the local is typed from the cell’s final representation. A local that already holds boxedmixedstorage works — amixedor union value, for example$p = $argc > 5 ? 1 : "x"; $q = &$p; replace($p);. See issue #1285. - Reference aliases to array elements (
$b =& $a[0]) are limited to indexed arrays with integer indices; associative arrays ($b =& $a['key']) are rejected at compile time. Referencing an out-of-range index binds the alias to a null cell instead of creating the element (PHP autovivifies it asnull), and the alias points into the array’s storage, so it is only valid while the array is alive and not reallocated by growth. - Reference elements inside array literals (
$r = [&$a, &$b];,['k' => &$a],array(&$a)) are rejected at compile time withReference elements in array literals ([&$x]) are not supported. PHP stores such an element as a reference cell aliasing the source variable, so$r[0] = 9writes through to$a. elephc’s arrays hold plain values and its only reference form points into array storage (the$b =& $a[0]case above), never out of it — an element aliasing a local would be a pointer to a stack slot the array can outlive. Assign the value and copy back, or alias an existing element with$b =& $a[0]. gotoand its target labels are not supported and are rejected at compile time (`goto` is not supported). elephc’s termination analysis, flow-sensitive narrowing, loop/branch pruning, and constant propagation all read control flow from the statement tree, which an arbitrary intra-function jump invalidates. Usebreak(includingbreak 2;),continue, a loop flag, or an earlyreturn. See Control Structures.++/--on astringfollows PHP exactly, including the perl-style alphanumeric carry ("az"++is"ba","Zz"++is"AAa") and the numeric-string retype ("9"++isint(10),"3.5"++isfloat(4.5)). Because the operator can change the value’s type, astringlocal that is a++/--target is given boxedmixedframe storage for its whole lifetime, so its runtime type follows the value rather than the declaration. The one divergence: PHP raisesE_DEPRECATEDfor++on a non-alphanumeric string and for--on a non-numeric string, and elephc has no runtime deprecation channel, so it produces the same value without the notice.++/--on an array, object, buffer, or pointer local is still rejected at compile time.- String offset writes (
$s[$i] = $v) follow PHP for string variables, with these gaps (see Strings): a write through a property ($o->s[0] = "x",C::$s[0] = "x") is rejected at compile time; the offset must be an integer, a float, or a numeric string literal (PHP also acceptsbool,null, and runtime strings, warning or throwing at run time); compound assignment,++/--,$s[] = $v, which always throwErrorin PHP, are compile errors instead; and when a warning handler replaces the variable in the middle of the write, PHP abandons the write and the expression isnull: elephc abandons it too for a string held inmixedorglobalstorage, but the expression still evaluates to the byte, and a plainstringlocal (reachable from a handler only as astaticor a by-reference parameter) keeps the written string instead of the handler’s value. Boxed and nested string writes share runtime offset conversion, including warnings for leading-numeric strings and scalar casts; nested writes retain their existing catchable errors. print_r($value, true)grows its capture into owned storage past the inline 64 KiB block, so the full rendered string is returned like PHP. Echo mode (print_r($value)) is unaffected.json_encode()of an instance of a declared class (notstdClass) encodes its declared public properties only: dynamic properties added to an#[\AllowDynamicProperties]class, or created by a deprecated runtime-named write, are not included.var_dump(),print_r()andvar_export()do list them, after the declared properties and in insertion order, exactly like PHP.func_num_args(),func_get_args()andfunc_get_arg()are compiled away rather than dispatched as builtin calls, sofunction_exists()reportsfalsefor the three names where PHP reportstrue. Their supported scopes are also narrower than PHP’s: they are rejected in a function with an optional (defaulted) parameter, in a function that already declares its own variadic, and in a method that overrides a parent method or implements an interface method. Everywhere else — functions, methods, static methods, closures, arrow functions, generators — they match PHP, including reporting the current values of the declared parameters. See Functions.- Surplus positional arguments (PHP allows any user function to be called with more arguments than it declares, discarding the extras) are only accepted by functions that use one of the three argument-introspection constructs above. Every other user function keeps elephc’s compile-time arity check, so
function f($a) {} f(1, 2);is a compile error where PHP runs it. serialize()/unserialize()cover scalars, arrays, and objects (including the__serialize/__unserialize/__sleep/__wakeupmagic methods andr:/R:object back-references) byte-for-byte compatibly with PHP. Objects are registered before property hydration, so self-references resolve correctly; unknown class names materialize as__PHP_Incomplete_Classand preserve their original wire name. Remaining gaps include the unsupported deprecatedSerializableinterface (C:wire form) and writes to properties of an unserialized object held in aMixednot persisting (a separateMixedproperty-write limitation). Malformed input emits an offset/length warning beforeunserialize()returnsfalse. A hydrated value that does not match a typed property’s declaration is rejected with PHP’s catchableTypeError(Cannot assign string to property C::$n of type ?int), spelling the value as PHP does (including the literaltrue/falsefor a bool) and naming the declared type (?int,?float,array, a class name, …). One gap remains: an object of the wrong class still hydrates an object-typed property, because the slot check verifies only that the value is an object, not that its class is the declared class or a subclass; the class/interface members of aniterableor union type are likewise not checked, and two properties of one serialized object that share a reference are hydrated as independent per-slot clones, so a shared rawintis widened per property where PHP keeps the shared value.- An uninitialized typed property (declared without a default and never assigned) is omitted by
print_r(),var_export(),get_object_vars()and(array), like PHP, butjson_encode()andserialize()still emit it (as0,""ornullfor its type) where PHP leaves it out, andvar_dump()names a nullable or union typeuninitialized(mixed)where PHP prints the declared type (uninitialized(?string)). - Reading a variable after a straight-line
unset()of it is a compile error (Undefined variable: $a), in both modes, where PHP warns and evaluates the read asnull. See Local retyping above for the full unset/retype/mixed-storage mechanism and theisset()/empty()/??probes that stay legal on the unbound name. defined('Class::CONST')follows PHP visibility for public, protected, and private members, including lexicalself::/parent::, runtime-called-classstatic::, and constants imported into classes through traits; invalid relative scopes raise a catchableError. The trait name itself is not a valid constant receiver. Eval-mode class-constant names remain unresolved, and dynamic and first-class-callabledefined()names are still rejected at compile time.is_a()andis_subclass_of()take a class NAME as well as an object, including an interface name, and follow PHP’s two different$allow_stringdefaults (is_subclass_ofaccepts a name unless told otherwise,is_aonly when told to). The flag itself may be computed at runtime and need not be abool: coercive mode admits any scalar there, and anint,stringorfloatis converted with PHP’s own truthiness, so"0"is false while"0.0"and" "are true. The name must be a compile-time literal: a name only known at runtime still answersfalserather than walking the hierarchy, because the emitted program has no name-keyed class table to consult. The same restriction is louder inclass_parents(),class_implements()andclass_exists(), which reject a non-literal name at compile time instead of answering. One gap of its own: an enum declared in the compiled program does not carry its implicitUnitEnum/BackedEnuminterfaces, sois_subclass_of("Suit", "UnitEnum")isfalsewhere PHP saystrue. This limitation also affects compiledis_a()andis_subclass_of()calls with a class-name string for an enum declared only insideeval(): those calls consult the AOT class table, which does not contain the eval declaration. A case of an enum declared insideeval()is an object subject instead; compiledis_a($case, "UnitEnum")andis_subclass_of($case, "UnitEnum")use the dynamic object bridge and report its implicit interfaces, includingBackedEnumfor a backed case. A compiled enum case returned througheval()retains incomplete AOT metadata: those object probes still returnfalsefor its implicitUnitEnum/BackedEnuminterfaces, while explicitly declared interfaces remain visible. For a compiled enum with animplementsclause,class_implements()lists only the directly declared interfaces; it omits their transitive parents and the implicitUnitEnum/BackedEnuminterfaces. An enum declared insideeval()reports transitive parents and its implicit enum interfaces to relation probes executed insideeval(), like PHP. Compiledclass_implements()also reports those interfaces for an eval-declared enum; it does not share the compiled relation probes’ limitation.strtotime()answersfalsefor some strings PHP parses. After an@<timestamp>it accepts only the one timezone token PHP ignores ("@123 UTC","@123abc"); a relative offset, time, zone identifier, UTC offset, or strtotime keyword after the epoch ("@123 +1 day","@123 12:30","@123 Europe/Rome","@123 noon") is not evaluated. An ISO date needs a four-digit year and two-digit time fields ("99-01-01"and"2020-01-05 9:30"arefalse), and a military zone letter after a time ("2024-06-15 12:30x", which PHP reads as UTC-11) is not recognized. Insideeval(),strtotime()supports onlynowand zero-padded ISO dates, and answers-1rather thanfalsefor anything else. See System & I/O for the forms that are supported.empty($box['k'])on a nullable static property holding anArrayAccessobject (public static ?Box $box) callsoffsetGet()without askingoffsetExists()first, where PHP asksoffsetExists()and skipsoffsetGet()for a missing offset. That property reads as a plainmixedvalue, so its class is not known whenempty()is lowered. Variables,$this, instance properties (nullable or not), non-nullable static properties and call results all go throughoffsetExists()first.PDOException::getCode()returns PDO’s SQLSTATE string ("HY000") only when the receiver is statically aPDOException: acatch (PDOException $e)block, or a parameter or property declaredPDOException. Through aThrowable,Exception, orRuntimeExceptionreceiver (catch (Exception $e), agetPrevious()result, aThrowableparameter) elephc typesgetCode()asintand reads the driver’s integer code (errorInfo[1]), where PHP returns the SQLSTATE string; aninstanceof PDOExceptioncheck on such a variable does not change that, and$e->errorInfodoes not compile on it (the property is declared onPDOExceptiononly). CatchPDOException, or after theinstanceofcheck pass the exception to a parameter declaredPDOException: through that receiver bothgetCode()and$e->errorInfo[0]give the SQLSTATE. See PDO.
Filesystem functions not implemented
These standard PHP filesystem functions are intentionally absent from elephc because they have no meaningful semantics in a compiled native binary:
move_uploaded_file(),is_uploaded_file()— both rely on the PHP-FPM/SAPI request lifecycle (the$_FILESsuperglobal and a per-request “uploaded files” registry). A standalone compiled binary has no such request scope.fgetss()— deprecated in PHP 7.3 and removed in PHP 8.0. New code should usestrip_tags()on the result offgets().
Compiler diagnostics
elephc reports errors with the file, line and column they come from. Example:
error[main.php:3:5]: Undefined variable: $name
error[src/Helpers.php:8:1]: Function 'foo' declared return type string but returns int
The compiler also emits non-fatal warnings (unused variables, unreachable code).
Runtime diagnostics
Runtime warnings flow through a suppressible diagnostics channel. The @ operator hides those warnings for its operand only, while fatal runtime errors and compile-time diagnostics remain visible. Current suppressible warnings include fopen() / file_get_contents() open failures and duplicate define() calls.
Literal property types
Standalone false properties and false parameters accept the exact boolean
false; values such as integer zero, null, and empty strings are rejected before
boolean coercion. Known instance, static, and parent calls retain this check.
Object refusals name the runtime class in their TypeError.
Initialize literal false properties in a constructor: declaration defaults
such as public false $ready = false are currently unsupported.
Ordinary standalone null properties accept only null and retain typed-property
initialization checks. The null declaration is distinct from a forbidden
void property.
Literal true property types are currently unsupported. Ordinary and promoted
properties, including nullable and union forms containing true, produce an
explicit compile diagnostic instead of silently widening to bool.