TypePHP Ahead-Of-Time PHP Compiler

TypePHP is an ahead-of-time (AOT) compiler from the Swoole team that translates a typed subset of PHP source into C++17 and then into native machine code. Unlike OPcache, which caches compiled opcodes, or the JIT, which turns hot opcodes into machine code while the program runs, TypePHP produces a standalone artifact ahead of time. That artifact is a native executable, a PHP extension, or a shared library, and it runs directly on the CPU without interpreting opcodes at all.

It is not a new language. TypePHP keeps PHP syntax and adds compile-time type information so the compiler can emit fast, statically typed C++ for the parts of a program where types are known. The compiler is written in PHP and is self-hosting: the tpc binary is produced by compiling TypePHP's own source with TypePHP. This lesson explains what it does, how to install and use it, the PHP it accepts, why teams reach for it, and where it does not help.

Two Projects, One Name

Search for "TypePHP" and you will find two unrelated projects. This lesson is about swoole/typephp, the AOT compiler. A separate package, typephp-php/typephp, is a runtime PHPDoc type enforcer that checks DocBlock annotations while your normal PHP process runs. They solve different problems, ship on different licenses, and share nothing but a name. When people say "TypePHP" in 2026 they almost always mean the Swoole compiler, and that is what the rest of this lesson covers.

What TypePHP Actually Does

A normal PHP program is parsed into opcodes and executed by the Zend Engine. Every array access, property fetch, and function call goes through the engine's dynamic machinery because any value can be any type at any time. That flexibility is why PHP is productive, and it is also why tight numeric loops are far slower than the equivalent C.

TypePHP changes the deal for the code you choose to compile. Where a function's parameter and return types are declared, and where local variables can be inferred to a single concrete type, the compiler lowers that function body to statically typed C++ and hands it to a native toolchain. Dynamic PHP values, internal functions, reflection, and object metadata still interoperate with the Zend runtime, but compiled user functions no longer pay the per-opcode interpretation cost once they are built.

The practical outcomes are:

  • Native binaries. Ship a CLI tool as a single executable with no separate PHP install on the target machine.
  • Compiled extensions. Move a CPU-bound module into a .so/.dll that a normal PHP-FPM or CLI process loads like any other extension.
  • Shared libraries and WASI components. Reuse compiled TypePHP code from other projects, or target WebAssembly for the browser and server sandboxes.

How The Compiler Works

TypePHP runs a deterministic two-phase build so multi-file projects compile reproducibly:

PHP source + .stub.php declarations + optional C/C++ sources
                     |
        parse, validate, and collect declarations
                     |
       lower function bodies and constants to C++17
                     |
       native compiler (GCC/Clang) + object and PCH caches
                     |
   executable  |  PHP extension  |  shared library  |  WASI component

Phase one reads every source file and builds a complete picture of the declared functions, classes, and constants before generating any code. Phase two lowers function bodies to C++ and compiles them, reusing cached object files and precompiled headers so incremental builds stay fast. The output artifact depends on the build mode.

Because the Zend runtime is still present for binary and extension builds, calls into internal functions (json_encode, preg_match, curl_exec, and so on) keep working, and extensions such as cURL, PDO, mysqli, and Swoole itself can be required explicitly.

Requirements

TypePHP needs a real C++ toolchain in addition to PHP:

  • PHP 8.4 or 8.5 CLI with development headers and php-config on PATH.
  • The PHP embed library (libphp.so on Linux, libphp.dylib on macOS) for binary and shared-library builds.
  • GCC 9+ or Clang with C++17 support.
  • CMake 3.24+ and Composer 2.
  • GMP and MPFR for the high-precision numeric types (libmpdec ships with the bundled PHPX runtime).

Two environment variables point the compiler at its dependencies when they are not in standard locations:

  • PHP_HOME — a PHP embed prefix containing bin/php-config, headers, and lib/libphp.so.
  • PHPX_HOME — a PHPX installation with include/ and lib/libphpx.so.

Install the system packages first:

# Debian / Ubuntu
sudo apt install build-essential cmake pkg-config libgmp-dev libmpfr-dev

# Fedora / RHEL / CentOS
sudo dnf install gcc gcc-c++ cmake pkgconf-pkg-config gmp-devel mpfr-devel

# Arch
sudo pacman -S base-devel cmake pkgconf gmp mpfr

Prebuilt release assets are published for Linux x64, Linux ARM64, macOS ARM64, and Windows x64, all built against PHP 8.5 ZTS. There are no NTS or 32-bit x86 release packages; on those you build from source.

Installing TypePHP

As a project dev dependency:

composer require --dev swoole/typephp
vendor/bin/tpc.php --help

From source, which is also how you get a self-hosted tpc binary:

git clone https://github.com/swoole/typephp.git
cd typephp
composer install
php bin/tpc.php --help

The command form is:

bin/tpc.php <file | directory | project.yml> [options] [-- program-args...]

Common options:

Option Meaning
-O <0-3> Optimization level (default 0; use -O3 for release builds)
-o, --output <file> Output artifact name
-m, --mode <bin|lib|ext> Build mode (default bin)
-r, --run Run the artifact after a successful build
-j, --job <n> Parallel compile jobs
-d, --debug Debug build with symbols and source mapping
--dry Generate C++ only; skip compile and link
--build-dir <dir> Where generated C++ and intermediates go
--php-version <8.4|8.5> PHP syntax version to parse against
--lto Link-time optimization
--wasm, --wasm=browser Target WASI 0.2, or the browser via Jco

Run bin/tpc.php --help for the authoritative list.

Your First Compiled Program

A binary needs a main() function as its entry point. Save this as hello.php:

PHP example
<?php

// no-execute — TypePHP source: compile it with `tpc`, do not run it with `php`.

function main(): void
{
    echo "Hello from a native binary!\n";
    var_dump(PHP_VERSION);
}

Compile and run:

bin/tpc.php hello.php
./hello

For a smaller artifact that drops the full VM, use the nano build:

bin/tpc.php --nano hello.php
./hello

Build Modes: Binary, Extension, Library

Binary mode (-m bin, the default) produces a standalone executable and requires a global main() with a strict signature. Use it for CLI tools and services:

bin/tpc.php app.php -O3 -o myapp

Extension mode (-m ext) compiles a directory of PHP into a PHP extension. There is no main(); the compiled functions and classes become available once the extension is loaded into a normal PHP process:

bin/tpc.php src/scoring/ -m ext -o scoring

This is the mode most web applications will use in practice: keep the framework, the router, and the I/O in ordinary interpreted PHP, and compile only the hot module.

Library mode (-m lib) emits a shared library plus a generated .stub.php so other projects can call the compiled API:

bin/tpc.php lib/ -m lib -o mylib

The Supported Subset Of PHP

TypePHP intentionally supports a defined, testable subset of PHP rather than claiming drop-in compatibility with every dynamic program. The main rules:

  • Global scope is declaration-only. Executable statements must live inside functions or methods. Top-level if, loops, and echo do not compile.
  • Binary mode requires a strict main() signaturemain(): void or main(int $argc, array $argv): void.
  • Inferred scalar types are fixed. A local variable the compiler infers as int, float, or bool uses native storage and cannot later hold a different type. Reassigning $count from an int to a string is a compile error, not a silent coercion.
  • Integers are native 64-bit by default. Inferred integers compile to fixed 64-bit values, so overflow wraps instead of promoting to float. Add use varint_types; to a file that needs PHP's overflow-to-float and non-integral integer-division behavior.
  • Some dynamic constructs are not supported. Variable variables ($$name), Closure::bind(), Closure::bindTo(), Closure::call(), by-reference variadic parameters on dynamic closures, and PHP 8.4 reflection lazy objects on compiled classes are all rejected.
PHP example
<?php

// no-execute — TypePHP source.

use varint_types;

function accumulate(int $limit): int
{
    $total = 0;

    for ($i = 0; $i < $limit; $i++) {
        $total += $i;
    }

    return $total;
}

Consult the project's incompatible PHP features list for the current, specific rules rather than assuming a construct works because it is not mentioned here.

Worked Examples

Every snippet below is TypePHP source. It parses as PHP but is meant to be compiled with tpc, not executed by the interpreter.

Native Numeric Loops

A recursive Fibonacci is the canonical "the interpreter is the bottleneck" case. With declared int types the compiler emits native integer arithmetic:

PHP example
<?php

// no-execute — TypePHP source.

declare(strict_types=1);

function fib(int $n): int
{
    if ($n === 1 || $n === 2) {
        return 1;
    }

    return fib($n - 1) + fib($n - 2);
}

function main(int $argc, array $argv): void
{
    $n = (int) ($argv[1] ?? '30');
    $start = microtime(true);

    echo fib($n), "\n";
    echo 'elapsed: ', number_format(microtime(true) - $start, 4), "s\n";
}
bin/tpc.php fib.php -O3 -o fib
./fib 35

Typed Containers And std::array

TypePHP ships strongly typed containers that compile to native data structures. The example below uses std::vector and std::orderedMap; the fixed-size std::array is the one in the published benchmark, where it reaches near-C++ speed on large update loops, roughly ten times faster than a PHP array:

PHP example
<?php

// no-execute — TypePHP source.

function main(): void
{
    $scores = std::vector(Type::Int);
    $scores[] = 10;
    $scores[] = 20;
    $scores[] = 30;

    $total = 0;
    foreach ($scores as $value) {
        $total += $value;
    }
    echo $total, "\n";

    $rank = std::orderedMap(Type::String, Type::Int);
    $rank['alice'] = 1;
    $rank['bob'] = 2;
}

Exact Arithmetic

The bundled big-number types give exact integer and decimal math as first-class typed values, rather than through bcmath-style string functions:

PHP example
<?php

// no-execute — TypePHP source.

function main(): void
{
    $a = std::bigInt('123456789012345678901234567890');
    $b = std::bigInt('987654321098765432109876543210');
    echo $a->add($b)->toString(), "\n";

    $sum = std::decimal('0.1')->add(std::decimal('0.2'));
    echo $sum->toString(), "\n"; // 0.3, not 0.30000000000000004
}

Universal Methods

TypePHP resolves method-style calls on scalars and arrays at compile time, so string and array helpers read fluently:

PHP example
<?php

// no-execute — TypePHP source.

function main(): void
{
    $name = 'ada lovelace';
    echo $name->upper(), "\n";
    echo $name->length(), "\n";

    $primes = [2, 3, 5, 7, 11];
    echo $primes->count(), "\n";
    var_dump($primes->contains(7));
}

Compile-Time Code Generation

Attributes generate boilerplate at compile time rather than at runtime through reflection:

PHP example
<?php

// no-execute — TypePHP source.

#[Printer(fields: ['id', 'name'])]
#[Arrayable(fields: ['id', 'name'])]
final class User
{
    #[Constructor, Getter, With]
    public int $id;

    #[Constructor, Getter, Setter]
    public string $name = 'guest';
}

function main(): void
{
    $user = new User(7);
    $user->setName('Ada');
    $renamed = $user->withId(8);

    echo $user->getId(), "\n";    // 7
    echo $renamed->getId(), "\n"; // 8
    echo (string) $user, "\n";
}

#[Getter], #[Setter], #[With], #[Constructor], #[Printer], and #[Arrayable] cover the common accessor and value-object patterns.

Mixing Hand-Written C++

For the rare hot path where you want to drop to C++ directly, provide the implementation and a .stub.php that declares its PHP-visible signature:

// math.cpp
#include <phpx.h>

using namespace php;

Int php_fast_sum(Int a, Int b) {
    return a + b;
}
PHP example
<?php

// no-execute — TypePHP stub (math.stub.php): declares the signature, no body.

function fast_sum(int $a, int $b): int {}

Add the C++ file, the stub, and the calling PHP to the project configuration and fast_sum() is available to compiled code. Note the C++ symbol carries a php_ prefix while the stub declares the PHP-visible name.

Project Configuration

Anything beyond a single file uses a project.yml:

name: myapp
mode: ext            # bin | ext | lib
php-version: "8.5"
optimize: 3
job: 8
build-dir: build
cxx-std: c++17

sources:
  - src
  - path: src/php85
    if: PHP_VERSION_ID >= 80500
  - path: src/windows
    if: PHP_OS_FAMILY == "Windows"

ignore:
  - src/experimental

ext-deps:
  - pdo_mysql
  - curl

link-libs:
  - curl

Paths resolve relative to the YAML file. Conditional sources entries support PHP_VERSION, PHP_VERSION_ID, and PHP_OS_FAMILY, which makes it possible to keep version- and platform-specific code in one project. ext-deps lists Zend extensions the compiled artifact needs at load time; forgetting one is a common first-build failure.

Benchmarks, And What They Do Not Promise

The project publishes results on the standard PHP language benchmarks:

bench.php (total)         5.034 s  ->  0.603 s   (~8x)
micro_bench.php (total)  13.045 s  ->  2.021 s   (~6.5x)

std::array update loop, 10000 x 100000 elements:
  PHP array (JIT)         67.6 s
  std::array (TypePHP)     6.4 s   (~10x)
  C++ std::vector          6.2 s

These measure raw language operations: function calls, property and array access, string handling, control flow. A typical web request spends most of its time in the database, in template rendering, in framework middleware, and waiting on network I/O, none of which AOT compilation speeds up. Treat the headline multipliers as the ceiling for a pure compute kernel, not a forecast for an end-to-end route. The honest question is always "how much of this request is CPU-bound PHP?" before "how fast could that part be?"

Who Uses It, And Why

TypePHP is a Swoole-team project, released as open source in 2026 and still pre-1.0 as of September 2026. Realistic adoption today clusters around cases where the compute is the point:

  • Compute-hot extensions dropped into an otherwise interpreted application: scoring, matching, image or signal processing, parsing, compression.
  • Numeric and cryptographic kernels where exact big-number types and native loops matter.
  • Standalone CLI binaries distributed to machines without a managed PHP runtime.
  • Browser and server WASI targets, where shipping a small native component beats shipping an interpreter.

Broad production use across mainstream web frameworks is not the current story, and the supported subset is deliberately narrow. The value proposition is "compile the 5% of the codebase that is a measured bottleneck," not "compile the app."

When To Reach For TypePHP

Work through cheaper options first:

  1. Profile. Confirm with real measurements that a specific PHP function is the bottleneck, not the database or an external call.
  2. Enable OPcache and the JIT. For many numeric workloads the JIT already gives a large fraction of the benefit with zero build tooling.
  3. Check for an existing extension. If the hot work is hashing, JSON, or image processing, a maintained C extension may already do it.
  4. Then consider TypePHP for a well-isolated, CPU-bound module with stable, well-typed code — compiled as an extension so the rest of the app is unchanged.

The cost side is real: a second toolchain (C++ compiler, CMake, PHP embed headers) in every build and CI environment, a narrower language subset in the compiled module, and an artifact whose ABI is tied to a specific PHP version. Write down why the module is compiled, next to the code, so a future maintainer does not "simplify" it back into interpreted PHP and lose the guarantee.

Failure Modes

  • php-config or the embed library is missing. Binary and shared-library builds fail at link time. Install the PHP development package and set PHP_HOME.
  • Compiling code that uses unsupported dynamic features. Variable variables, closure rebinding, and retyping an inferred scalar are rejected at compile time. Restructure the module or leave it interpreted.
  • Expecting drop-in speedups on I/O-bound routes. If the request is dominated by database and network time, a compiled artifact changes almost nothing.
  • ABI and PHP-version mismatch. An extension compiled against PHP 8.5 ZTS will not load into a PHP 8.4 or NTS process. Pin the build and runtime PHP together.
  • Forgetting ext-deps. A compiled artifact that calls curl_* or PDO needs those extensions listed so they are required at load time.

Official References

What You Should Be Able To Do

After this lesson, you should be able to explain how AOT compilation differs from OPcache and the JIT, describe TypePHP's build pipeline and its three output modes, install the compiler and its toolchain on a Linux host, identify which PHP constructs fall outside its supported subset, read a project.yml, and decide — from profiling evidence rather than enthusiasm — whether a specific module is a good candidate for compilation or whether a cheaper option solves the problem.

Practice

Task: Write A TypePHP Preflight Check

Before a build machine can compile anything with TypePHP, it needs the right PHP version, a thread-safe or compatible build, a few extensions, and the php-config helper on PATH. Write a plain PHP script that reports whether the current host meets those prerequisites.

Requirements

  • Use declare(strict_types=1);.
  • Check that the PHP version is at least 8.4 using PHP_VERSION_ID.
  • Report whether the build is thread-safe using PHP_ZTS.
  • Check that the gmp and mbstring extensions are loaded.
  • Report PHP_OS_FAMILY.
  • Scan the directories in the PATH environment variable and report whether php-config is present, without shelling out.
  • Print one line per check with a clear OK or MISSING / WARN marker, then a final summary line saying whether the host is ready.
  • The script must run to completion and exit cleanly on any host.

Check Your Work

Run the script with php preflight.php. Every line should be readable on its own, and the summary should reflect the individual checks.

Show solution

This script only inspects values PHP already knows about itself plus the PATH variable, so it is safe to run anywhere and never depends on a C++ toolchain being installed.

PHP example
<?php

declare(strict_types=1);

/**
 * @return array{label: string, ok: bool, detail: string}
 */
function check(string $label, bool $ok, string $detail): array
{
    return ['label' => $label, 'ok' => $ok, 'detail' => $detail];
}

function phpConfigOnPath(): bool
{
    $path = getenv('PATH') ?: '';

    foreach (explode(PATH_SEPARATOR, $path) as $dir) {
        if ($dir !== '' && is_file($dir . DIRECTORY_SEPARATOR . 'php-config')) {
            return true;
        }
    }

    return false;
}

$checks = [
    check(
        'PHP version >= 8.4',
        PHP_VERSION_ID >= 80400,
        PHP_VERSION,
    ),
    check(
        'Thread-safe build (ZTS)',
        (bool) PHP_ZTS,
        PHP_ZTS ? 'ZTS' : 'NTS (release assets are ZTS; source builds can be NTS)',
    ),
    check('gmp extension', extension_loaded('gmp'), 'exact big-integer math'),
    check('mbstring extension', extension_loaded('mbstring'), 'string handling'),
    check('php-config on PATH', phpConfigOnPath(), 'needed to locate PHP headers'),
];

$blocking = ['PHP version >= 8.4', 'php-config on PATH'];
$ready = true;

foreach ($checks as $result) {
    if ($result['ok']) {
        $marker = 'OK';
    } else {
        $marker = in_array($result['label'], $blocking, true) ? 'MISSING' : 'WARN';
    }

    if (!$result['ok'] && in_array($result['label'], $blocking, true)) {
        $ready = false;
    }

    printf("[%-7s] %-24s %s%s", $marker, $result['label'], $result['detail'], PHP_EOL);
}

echo PHP_EOL;
echo $ready
    ? 'Preflight: required checks passed. Verify the C++ toolchain (gcc/clang, cmake) separately.' . PHP_EOL
    : 'Preflight: not ready. Resolve the MISSING checks above before building.' . PHP_EOL;

// Prints (example):
// [OK     ] PHP version >= 8.4       8.5.8
// [WARN   ] Thread-safe build (ZTS)  NTS (release assets are ZTS; source builds can be NTS)
// [OK     ] gmp extension            exact big-integer math
// [OK     ] mbstring extension       string handling
// [MISSING] php-config on PATH       needed to locate PHP headers
//
// Preflight: not ready. Resolve the MISSING checks above before building.

Why This Works

The intended behavior is a readable, host-specific report: each line stands alone, and the summary is driven only by the checks that actually block a build (PHP version and php-config). Missing gmp or a non-ZTS build are warnings, not hard stops, because a source build can proceed without them in some configurations. The important failure case is a machine that looks fine for running PHP but cannot compile — no php-config, no headers — and the script names that explicitly instead of letting the first tpc run fail with a confusing linker error. The evidence is the constants PHP reports about its own build plus a direct scan of PATH, so the result reflects the real interpreter that would drive the compiler.

Practice: Choose A Native Speedup Path

A product API has one endpoint that ranks search results with a pure-PHP scoring function. Profiling a representative request shows total server time of 520 ms, of which 400 ms is inside scoreCandidates() — a CPU-bound loop over a few thousand candidates with no I/O. The rest is database and serialization. Traffic is steady and the scoring code changes roughly twice a year.

You are considering three options: enable the PHP JIT, rewrite scoreCandidates() as a hand-written C extension, or extract the scoring module and compile it with TypePHP in extension mode.

Recommend one, and say what you would measure to confirm it worked.

Your answer must identify the intended behavior, the important failure case, and the evidence that proves the result.

Show solution

Intended behavior. The endpoint should return the same ranking it does today, with the 400 ms spent in scoreCandidates() cut substantially, and without adding risk or toolchain cost to the parts of the request that are already fast.

Recommended order. Try the JIT first. It is a configuration change (opcache.jit_buffer_size and opcache.jit), it needs no build tooling, and a numeric loop over scalars is close to its best case. If a measured run with the JIT enabled brings scoreCandidates() down far enough, stop there.

If the JIT is not enough, compile the scoring module with TypePHP in extension mode. The function is a good candidate: it is CPU-bound, well isolated, has stable well-typed inputs, and changes rarely, so the cost of carrying a C++ toolchain in CI is amortized over a long stable period. Extension mode keeps the router, the database layer, and serialization in ordinary interpreted PHP, so the blast radius is one module.

Do not hand-write a C extension here. It would likely be faster still, but it duplicates logic in a second language, needs C review skills on the team, and the twice-a-year changes would each become a C change. The maintenance cost is not justified by the difference between "native C++ from TypePHP" and "native C by hand" for a few thousand iterations.

Important failure case. The compiled extension is built against one PHP version and thread-safety mode. If production runs a different PHP than the build image, the extension will not load and the endpoint breaks entirely rather than running slowly. Pin the build and runtime PHP together, and gate the deploy on the extension actually loading (php -m in the release image, plus a smoke request) before it takes traffic. A second failure case is scope creep: if someone later adds an I/O call or a dynamic-reflection path inside the compiled module, it either stops compiling or the speedup evaporates.

Evidence that proves the result. Profile the same representative request before and after with the same input fixture. Show scoreCandidates() wall time dropping, total endpoint p95 and p99 dropping, and identical ranking output on a fixed set of queries (a golden-file comparison). Confirm the extension loads in the production SAPI, and watch error rate and latency for a full traffic cycle after rollout. One faster local run is not proof; matching output plus a stable production latency curve is.

Practice: Spot The Unsupported PHP

A colleague wants to compile the file below with TypePHP in binary mode and cannot get it to build.

PHP example
<?php

// no-execute — sample under review, not meant to run.

$setting = 'rate';
$$setting = 0.2;

final class Candidate
{
    public function __construct(public float $base, public float $computed = 0.0)
    {
    }
}

function apply(Candidate $input, float $rate): Candidate
{
    $input->computed = $input->base * $rate;
    return $input;
}

$describe = function (): string {
    return 'base=' . $this->base;
};
$bound = Closure::bind($describe, new Candidate(100.0), Candidate::class);

$scored = apply(new Candidate(100.0), $rate);
$result = $scored->computed;
$result = 'done: ' . $result;

echo $result, "\n";
echo $bound(), "\n";

Identify which parts fall outside TypePHP's supported subset and why, and describe how you would restructure the file so it compiles.

Your answer must identify the intended behavior, the important failure case, and the evidence that proves the result.

Show solution

Intended behavior. The file should compute base * rate for a candidate, print the result, and print a short description of the candidate. Compiled in binary mode, it needs a single main() entry point, statically knowable types, and no constructs the compiler cannot resolve ahead of time.

What TypePHP rejects, and why:

  • Top-level executable statements. The $setting assignments, the closure assignment, Closure::bind(), the call to apply(), and both echo lines all run in global scope. Global scope is declaration-only; only declarations, use, declare, and constant definitions may appear there. Every one of these has to move inside a function.
  • No main(). Binary mode requires a main(): void or main(int $argc, array $argv): void entry point. There is none.
  • Variable variables. $$setting = 0.2 creates a variable whose name is only known at runtime. The compiler cannot allocate a typed slot for a name it cannot see, so $$var is unsupported outright. Use an ordinary variable.
  • Closure rebinding. Closure::bind(), Closure::bindTo(), and Closure::call() are not supported. Replace the rebound closure with a method on the class.
  • Scalar variable retyped. $result is first inferred as a float (the value of computed), then reassigned a string. Inferred int, float, and bool locals use fixed native storage, so reusing the name for a different type is a compile error. Use a second variable.

The Candidate class itself is fine. Promoted constructor properties with declared types are exactly the shape the compiler wants.

Restructured shape:

PHP example
<?php

// no-execute — TypePHP source.

declare(strict_types=1);

final class Candidate
{
    public function __construct(public float $base, public float $computed = 0.0)
    {
    }

    public function describe(): string
    {
        return 'base=' . $this->base;
    }
}

function apply(Candidate $input, float $rate): Candidate
{
    $input->computed = $input->base * $rate;
    return $input;
}

function main(): void
{
    $rate = 0.2;
    $candidate = new Candidate(100.0);

    $scored = apply($candidate, $rate);
    $message = 'done: ' . $scored->computed;

    echo $message, "\n";
    echo $scored->describe(), "\n";
}

Important failure case. The subtle one is the retyped $result. In plain PHP it runs fine and nobody notices; under TypePHP it fails to compile, and if a reviewer "fixes" the build by loosening types they lose the static guarantee that made compilation worthwhile. The Closure::bind() line is the one with no drop-in replacement: the logic has to move onto the class as a real method.

Evidence that proves the result. The restructured file compiles with tpc file.php -o scorer and no diagnostics, ./scorer prints done: 20 followed by base=100, and the same code run under interpreted PHP produces the same two lines. A passing compile plus matching output against the interpreter is the proof that the subset rules were satisfied without changing behavior.