A typed dialect of PHP 8.5+ · pre-alpha

PHP 10 you don’t have to wait for.

Wait too long for a thing and you stop needing it. Phaenon is the strict, modern PHP you write today: generics as syntax, typed collections, structured concurrency, a type checker in the compiler. Less to write, easier to review. Every .phn compiles to idiomatic PHP and runs on the PHP you already have. Editor plugins and agent skills included.

  • one Go binary
  • PHP 8.5 and 8.6 out
  • PHPStan level max clean
  • a line map in every file
  • MIT
src/Persistence/Repository.phnyou write
namespace App\Persistence;

use App\Model\Aggregate;
use App\Model\EventStream;

final class Repository<T of Aggregate>
{
    fun __construct(
        private readonly class<T> $type,
        private readonly EventStream $stream,
    ) {}

    fun find(AggregateId $id): ?T {
        $events = $this->stream->read($id);
        if ($events === []) {
            return null;
        }

        return $this->type::reconstitute($events);
    }

    fun save(T $root): void throws ConcurrencyConflict =>
        $this->stream->append($root->id, $root->releaseEvents());

    fun ids(list<T> $roots): list<AggregateId> =>
        $roots |> array_map(fn (T $root) => $root->id, ?);
}

Type parameters are syntax and erase to the docblocks PHPStan and PhpStorm read. A class that needs its T at runtime takes a class<T>, the type of User::class; the compiler adds nothing you did not write.

One file in, one file out

Every .phn compiles to one .php with the same namespace, classes, functions and behaviour. The compiler adds a header, strict_types, docblocks, the runtime call behind a structural is or as, and nothing you did not write: no hidden parameters, no synthesised members, no renamed locals.

Mixed projects are normal

.phn and .php share a Composer package. Phaenon reads PHP's types from signatures and docblocks; PHP calls Phaenon without knowing. Symfony, Laravel, PHPUnit, PHPStan and the extensions keep working.

PHP's runtime, untouched

Evaluation order, copy-on-write values, references, integers and floats are PHP's. A dialect, not another language in PHP's syntax, and no runtime of its own: the output targets PHP 8.5 or 8.6 and uses their features directly.

What it adds

The parts PHP leaves to comments, as syntax.

Everything PHPStan's vocabulary says in docblocks has a spelling in the language, checked by the compiler and erased on the way out.

class Box<T of Item>

Generics as syntax

class Box<T>, fun first<T>(list<T> $items): ?T, bounds, variance, type aliases. They erase to the docblocks PHPStan and PhpStorm read. A class that needs its T at runtime takes a class<T>, the type of User::class.

map<string, list<int>>

list<T> and map<K, V>

Two collection types instead of one array that is both; a tuple is (A, B). array stays as a legacy type for interop, convertible both ways under the checker's rules.

(bytes) $raw

string and bytes

string is text, UTF-8 by definition; bytes is binary. The split lives in the types and erases to string at runtime; the checked conversions are in phaenon/runtime.

fun id(): int => $this->id;

fun, => and throws

fun declares a function or method, public unless written otherwise. A one-expression body is => expr. throws A|B names what escapes and compiles to the @throws tag the analysis reads.

spawn $this->users->find($id)

Structured concurrency

concurrently { } and spawn over amp v3 and Revolt. A block owns its tasks and ends when they have; a failure cancels the siblings. No function is coloured async: a request handler enters a block as it is.

$x is list<int>

is and as

$x is list<int> tests a structural type at runtime; $x as User asserts it and narrows. A plain type compiles to the PHP check, a structural one to phaenon/runtime.

#[Map(fn ($row) => $row->id)]

Closures in attributes

A closure in a constant expression, lowered to the static function PHP 8.5 accepts there, with the checker keeping PHP's rule: no $this, no use.

$xs |> array_map($f, ?)

Pipes, lowered

PHP 8.5's |> with PHP 8.6's partial application, written once and lowered exactly for an 8.5 target. phn migrate turns nested call chains into pipelines where the rewrite is exact.

Strict by construction

The type checker is the compiler.

phn check reads the whole program: your files, vendor code through Composer's autoload maps, and the php-src stubs built into the binary. Inference, narrowing and flow analysis. Stricter than PHPStan at level max and never more lenient, so the output is level-max clean by construction.

Stable codes
P1xxx syntax and removed constructs, P2xxx types and flow, P3xxx generics, P4xxx interop. One mistake, one diagnostic, with the source line and a caret under the expression.
Four formats
Text for a terminal, JSON for editors, --format=github for annotations on the .phn lines, Checkstyle for other CI systems.
Dead code as warnings
A private member nothing uses, a property only written or only read, a catch nothing in its try can reach, a throws nothing in the body throws.
No output on error
phn build type-checks first and withholds the .php of a file with an error. A code base adopting the checker builds while it fixes its findings with --emit-on-error.
src/Cart.phn
namespace App\Shop;

final class Cart
{
    /** @var list<Line> */
    private array $lines = [];

    fun add(Line $line): void {
        $this->lines[] = $line;
    }

    fun first(): Line => $this->lines[0] ?? null;

    fun skus(): list<string> =>
        $this->lines |> array_map(fn (Line $line) => $line->sku, ?);

    private fun count(): int => \count($this->lines);
}

final readonly class Line
{
    fun __construct(public string $sku, public int $price, public int $quantity) {}
}
$ phn checkexit 1
src/Cart.phn:12:26: error P2202: App\Shop\Cart::first() should return App\Shop\Line but returns App\Shop\Line|null
   12 |     fun first(): Line => $this->lines[0] ?? null;
      |                          ^^^^^^^^^^^^^^^^^^^^^^^
src/Cart.phn:17:17: warning P2406: private method App\Shop\Cart::count() is never used
   17 |     private fun count(): int => \count($this->lines);
      |                 ^^^^^
1 files checked: 1 errors, 1 warnings

Readable PHP out

The output is PHP a person wrote.

Laid out as PER Coding Style has it, imports in the use block, docblocks where PHPStan reads them. Not a runtime, not a bundle: a file you can read, step through and ship.

Every compiled file ends with a line map. __LINE__ compiles to the source line, and phn trace turns whatever reports a compiled line into the .phn's: a stack trace, PHPUnit, PHPStan, a coverage report. In production the Monolog processor and the Sentry integration of phaenon/runtime do the same; in PhpStorm the plugin reads the map for navigation and for Xdebug.

the tools keep running on the outputs
vendor/bin/phpunit | phn trace       # .tests/FooTest.php:42 → tests/FooTest.phn:37
phn trace --root /app=. < trace.txt  # /app/.src/Foo.php(18) → src/Foo.phn(9)
phn fix -- vendor/bin/php-cs-fixer fix   # its changes, carried back into the .phn
adopting Phaenon in a PHP project
phn migrate src tests   # Foo.php → Foo.phn; docblock generics become syntax
phn check               # the type checker; exit 1 on an error
phn build               # src/ → .src/, tests/ → .tests/

In a project

A file at a time. The compiler where the code is written.

phn migrate rewrites a PHP file as Phaenon: the open tag goes, PHPStan's docblock generics become syntax, public function becomes fun, nested call chains become pipelines where the rewrite is exact. Every tag it leaves in place is reported with the reason. A project typed for PHPStan migrates with its types intact; the rest is typed as it goes.

phn runs where code is written and released: on the developer's machine, in CI, in the release step. Never on the consumer's. A library publishes the compiled PHP beside its .phn, the way a TypeScript package ships its JavaScript, so composer require brings plain PHP that needs no compiler, no plugin and no build step.

phaenon.json names each source root and its sibling output directory: src/ compiles into .src/, tests/ into .tests/. Composer's PSR-4, PHPUnit, PHPStan, php-cs-fixer and Xdebug point at the outputs; the repository and the IDE hold the sources. The output sits at the same depth, so __DIR__ resolves where the source's does. A target per project, PHP 8.5 or 8.6: what is newer than the target is lowered where that is exact and refused otherwise.

a library written in Phaenon
acme/money                  # a library written in Phaenon
├── src/Money.phn           # what you write and review
├── .src/Money.php          # what phn build wrote, published with the package
├── phaenon.json            # {"roots": {"src": ".src"}, "php": "8.5"}
└── composer.json           # "psr-4": {"Acme\\Money\\": ".src/"}
phaenon.json
{"roots": {"src": ".src", "tests": ".tests"}, "php": "8.5"}
ci.yaml
- run: phn check --format=github   # findings as annotations on the .phn lines
- run: phn fmt --check             # the layout, then the project's fixer
- run: phn build --check           # every output current, none orphaned
- run: vendor/bin/phpunit | phn trace

What it removes

Nothing from the runtime. These, from the language.

Phaenon keeps what makes PHP PHP: $variables, classes, enums, readonly, property hooks, attributes, closures, match, named arguments, union and intersection types, exceptions, generators, fibers, the request cycle, Composer. The parser or the checker refuses what is on the right, and the emitted PHP never contains it.

<?php, ?>, inline HTML
a file is code from byte 0
array as the only collection type
list<T>, map<K, V>, tuples; array stays as a legacy type
the string and mbstring duality
string is text, bytes is binary
untyped code
every declaration has a type; mixed is written, never inferred; strict_types is implied
global, goto, eval, extract, compact, $$var
dynamic scope defeats a type checker
include and require inside functions
a bootstrap file at the top level stays
the alternative syntax
if: … endif; goes; braces, one syntax
var, (integer) and (boolean) casts, list()
public, canonical casts, [] destructuring
tabs
spaces, as PER Coding Style requires

Tooling

One binary, eight verbs.

phn is one static Go binary for macOS, Linux and Windows, reproducible from the sources: two builds from the same sources give the same bytes.

Every verb reads the same phaenon.json: the roots, the target, what to leave alone. The editors and the assistants are below.

phn check
the type checker: diagnostics with codes, as text, JSON, GitHub annotations or Checkstyle
phn build
type-checks, then writes the outputs; --check for CI, where every output must be current
phn migrate
.php → .phn: docblock generics lifted into syntax, fun, throws, pipelines, the canonical layout; --list-types types bare arrays as lists and maps from the values they hold
phn fmt
Phaenon's layout, then the project's own php-cs-fixer through phn fix
phn fix
php-cs-fixer or Rector on the outputs, their changes carried back into the .phn
phn trace
a stack trace, PHPUnit, PHPStan or a coverage report, repositioned onto .phn lines
phn lsp
the checker as a language server: diagnostics, hover, definition, completion, build on save
phn watch
check and build on every change, for a terminal

Ecosystem

The editor you have, and the assistant beside it.

JetBrains plugin

PhpStorm reads .phn as PHP without the open tag, so the whole PHP toolchain applies: highlighting, formatting in Phaenon's layout, completion with Phaenon's types, navigation and refactorings across .phn and .php, every PHP inspection, and Xdebug on the source through the line map. phn lsp adds the checker's diagnostics and the build on save.

VS Code extension

The grammar, and the project's compiler as the language server: diagnostics with the checker's codes, hover with the type in Phaenon's spelling, go to definition into .phn, vendor .php and the stubs, outline, completion, signature help, Format Document, build on save.

Any other editor

phn lsp speaks LSP over stdio and runs the project's own vendor/bin/phn, so a file is checked with the compiler the project builds with; phn watch does the same from a terminal.

Agent skills

Skills for coding assistants — how Phaenon is written, checked, migrated and built, its diagnostics and its layout — written for the assistants people code with, open source like the language, and in the works in the compiler's workspace.

Status

Pre-alpha. Built on real code.

Each milestone opens as a design document and closes when a real code base runs on it. Four libraries build from .phn with their own test suites, PHPStan at level max and php-cs-fixer passing on the output, and a command-line tool of 859 .phn files, its fifteen fan-outs written as concurrently blocks, runs its 2,052 tests on the compiled output with phn check clean. The platform behind tequila.dev and its tenants run on Phaenon.

  1. M0Foundationsdone

    Lexer, parser and printer conformance-tested against php-src's .phpt corpus: token-identical lexing, every PHP-accepted file parses and round-trips, printed files run identically under php.

  2. M1First builddone

    phn build and phn migrate for the PHP subset; a first library built from .phn with its suite, PHPStan and php-cs-fixer passing.

  3. M2Type checkerdone

    phn check over the PHP subset and the PHPStan docblock vocabulary; on the first two libraries it reports exactly what PHPStan reports at level 10.

  4. M3Genericsdone

    class Box<T>, list<T>, map<K, V>, class<T>, Closure(T): U, is / as and type aliases, compiled to PHP with PHPStan docblocks; phn migrate lifts docblock generics into syntax.

  5. M4Collections and stringsdone

    list<T>, map<K, V>, tuples and bytes; fun with public implied; closures in attributes; phaenon.json with sibling output directories. Four libraries build from .phn with their suites passing.

  6. M5Concurrencydone

    concurrently and spawn over amp and Revolt, compiled onto phaenon/concurrent. An 859-file command-line tool runs its 2,052 tests on the compiled output with phn check clean.

  7. 8.6PHP 8.6in progress

    The target per project, 8.5 or 8.6; partial application typed and lowered; the library known by version. Closes on PHP 8.6.0's release day.

  8. M6Tooling and distributionin progress

    phn fix, phn fmt, phn lsp with the PhpStorm plugin and the VS Code extension, phn trace, the CI formats, the release binaries and this site.

Phaenon is MIT-licensed. The compiler, phaenon/runtime, phaenon/concurrent, the VS Code extension and the JetBrains plugin live under github.com/phaenonlang.

Get the compiler.

One binary per platform, pinned by its SHA-256, and the Composer package that fetches it.

Download phn