The tyhp/lambda Package

The tyhp/lambda package is the runtime behind parsable lambdas. When you pass an inline fn to a parameter typed Expression<callable(T): R> or PropertyPath<callable(T): R>, the compiler builds objects from this package that describe the lambda's code as a tree. Your library receives that tree, inspects it, and translates it into something else: a SQL WHERE clause, a validation rule, a column name, a serialized query sent to another service.

This page is the API reference and usage guide for the package. It covers every public class, the node types that make up a tree, how to walk and translate a tree, the fluent builders that let plain PHP code create trees, and the JSON format produced by the serializer. For the language side — which fn bodies can become trees, how types are inferred, and which compiler errors you can get — see Parsable Lambdas and Expression Trees.

Installation

tyhp build adds tyhp/lambda to your composer.json when your compiled code uses Expression or PropertyPath, so you rarely add it by hand. The package depends on tyhp/core. Version pins follow the same 80N.X.Y scheme as the other compiled tyhp/* packages; Composer Runtime Packages explains which pin to use, and Runtime Packages Overview shows how the packages relate.

The package uses three namespaces:

Namespace Contents
Tyhp\ Expression, PropertyPath, and the fluent builders PropertyPathBuilder and ExpressionBuilder
Tyhp\Expression\ The node classes, ExpressionVisitor, ExpressionSerializer, and the Serialized* struct types
Tyhp\Expression\Structs\ The PathNode struct

How the pieces fit together

An expression tree has two halves. The container is an Expression object: it knows the lambda's parameters, its return type, and a compiled closure that runs the original code. The body is a tree of ExpressionNode objects, one node per operation in the lambda's body.

Tyhp\Expression<TCallableShape>            body, parameters, callable, returnType
├── Tyhp\PropertyPath<TCallableShape>      adds the property chain ($path)
│   └── Tyhp\PropertyPathBuilder<T>        fluent builder, created by Expression::of()
└── Tyhp\ExpressionBuilder<T>              fluent builder, created by a comparison

Tyhp\Expression\ExpressionNode             abstract base: $type, $nodeType, accept()
├── ParameterExpression                    the lambda parameter ($u)
├── PropertyAccessExpression               $u->age
├── NullSafeAccessExpression               $u->address?->city
├── MethodCallExpression                   $u->displayName()
├── StaticMethodCallExpression             Money::of($u->amount)
├── BinaryExpression                       $u->age >= 18, $a && $b
├── UnaryExpression                        !$u->isActive
├── ConstantExpression                     18, $capturedVariable, User::ADULT_AGE
├── TernaryExpression                      $c ? $a : $b
├── CoalesceExpression                     $a ?? $b
├── ArrayAccessExpression                  $u->tags[0]
├── CastExpression                         (string) $u->age
├── NewExpression                          new Address($u->city)
└── InstanceofExpression                   $u->address is Address

Trees reach your code in one of four ways:

  • From the compiler, as an Expression. An inline fn passed to an Expression<callable(...): R> parameter becomes a full tree of nodes.
  • From the compiler, as a PropertyPath. An inline fn that is only a property chain (fn ($u) => $u->address->city), passed to a PropertyPath<callable(T): R> parameter, becomes a PropertyPath with the chain as an array of names.
  • From the fluent builders. Plain PHP code cannot write parsable lambdas, so Expression::of(User::class)->age->gte(18) builds an equivalent tree by method calls. This is how PHP callers use a Tyhp library that expects expression trees.
  • By constructing nodes yourself. Every node class and Expression have public constructors. This is useful in tests and when you rebuild a tree.

The examples on this page use these two classes:

<?tyhp

namespace App;

class Address {
    public function __construct(
        public string $city,
        public string $country,
    ) {}
}

class User {
    public const int ADULT_AGE = 18;

    public function __construct(
        public string $name,
        public int $age,
        public bool $isActive,
        public Address $address,
        public ?string $nickname = null,
        public array<string> $tags = [],
    ) {}

    public function displayName(): string {
        return $this->nickname ?? $this->name;
    }
}

Getting started

A function that accepts Expression<callable(User): bool> receives a tree instead of a closure. It can look at the tree, and it can still run the original code by calling the expression like a function. A function that accepts PropertyPath<callable(User): mixed> receives the property chain.

<?tyhp

namespace App;

use Tyhp\Expression;
use Tyhp\PropertyPath;

final class Inspector {
    public static function describe(Expression<callable(User): bool> $predicate): void {
        echo $predicate->body->nodeType, "\n";        // binary
        echo $predicate->parameters[0]->name, "\n";   // u
        echo $predicate->returnType, "\n";            // bool

        $ada = new User('Ada', 36, true, new Address('Oslo', 'NO'));
        echo $predicate($ada) ? 'matches' : 'does not match', "\n";
    }

    public static function column(PropertyPath<callable(User): mixed> $path): string {
        return $path->getPath();
    }
}

int $minAge = 18;
Inspector::describe(fn ($u) => $u->age >= $minAge && $u->isActive);
echo Inspector::column(fn ($u) => $u->address->city), "\n";   // address.city

The captured variable $minAge appears in the tree as a ConstantExpression holding 18. Its value is read each time the call runs, so a different $minAge produces a different tree.

Expression

Expression<TCallableShape>

Tyhp\Expression is the container for a tree. Its single type argument is the callable shape of the lambda: Expression<callable(User): bool> describes a lambda that takes a User and returns bool; Expression<callable(User, User): int> takes two; Expression<callable(): string> takes none. Every Expression keeps the compiled closure, so code that receives one can either inspect it or run it.

PropertyPath, PropertyPathBuilder, and ExpressionBuilder all extend Expression, so everything in this section applies to them too.

PropertyPath

PropertyPath<TCallableShape>

Tyhp\PropertyPath is an Expression that is only a chain of property reads. Use it for APIs that need a property reference rather than a predicate: ORM column mappings, orderBy, select, form field bindings, validation messages. Because the chain is written as code, renaming the property in your IDE updates every reference, and a typo is a compile error.

When an fn is passed to a PropertyPath<callable(T): R> parameter, the compiler emits a new \Tyhp\PropertyPath(...) with the source class, the result type, the array of property names, and your fn as the closure. Chains that use ?-> also pass a nullSafeFlags array. The compiled output is shown on the language page.

The tree in $body is built from the chain, not from your fn. Its parameter is always named source, with paramType set to the source class, and every property node has the type 'mixed':

fn ($u) => $u->address->city

PropertyAccessExpression  property: 'city'
└── PropertyAccessExpression  property: 'address'
    └── ParameterExpression  name: 'source', paramType: 'App\User', index: 0

Fluent builders for PHP callers

A Tyhp library can expose methods that take Expression<callable(T): bool> or PropertyPath<callable(T): R>. Tyhp callers pass an fn. PHP callers cannot, because the PHP compiler does not capture lambda bodies. The fluent builders fill that gap: they build the same kind of tree from method calls, starting at Expression::of().

<?php

use App\User;
use Tyhp\Expression;

// PropertyPath: a property reference
$sortColumn = Expression::of(User::class)->address->city;

// Expression: a predicate
$isEligible = Expression::of(User::class)->age->gte(18)
    ->and()->isActive->same(true);

$repository->orderBy($sortColumn)->where($isEligible);

Builder lifecycle

A chain moves through these states. Each method returns a new builder; builders are never modified in place, so you can keep an intermediate builder and branch from it.

  1. Expression::of(User::class) returns a PropertyPathBuilder with an empty path.
  2. Property access (->address, ->prop('address')) appends a segment and returns another PropertyPathBuilder. A PropertyPathBuilder is a PropertyPath, so you can pass it anywhere a PropertyPath is expected.
  3. A comparison (->gte(18), ->eq('Oslo'), ->isNull(), …) turns the path into a completed ExpressionBuilder whose body is path <operator> value. This step is one-way.
  4. ->and() or ->or() with no argument starts a right-hand side. Property access then builds the right-hand path, and a comparison completes the compound expression.
  5. ->and($other) or ->or($other) combines with an existing expression in one step.

The ->name property syntax is for PHP. It works through __get(), and the classes carry @mixin T docblock tags so PhpStorm, PHPStan, and Psalm autocomplete the source class's property names. In Tyhp code, use ->prop('name') or pass segments to Expression::of().

<?tyhp

use Tyhp\Expression;
use Tyhp\ExpressionBuilder;
use Tyhp\PropertyPathBuilder;

PropertyPathBuilder<User> $city = Expression::of(User::class)->prop('address')->prop('city');
ExpressionBuilder<User> $adult = Expression::of(User::class)->prop('age')->gte(18);
ExpressionBuilder<User> $active = Expression::of(User::class, 'isActive')->same(true);
ExpressionBuilder<User> $eligible = $adult->and($active);

Comparison methods

Both builders have the same ten comparison methods. On a PropertyPathBuilder they compare the current path. On an ExpressionBuilder they complete the right-hand side after and() / or(). Each returns ExpressionBuilder<T>.

Method Operator in the tree Meaning
gt(mixed $value): ExpressionBuilder<T> > greater than
gte(mixed $value): ExpressionBuilder<T> >= greater than or equal
lt(mixed $value): ExpressionBuilder<T> < less than
lte(mixed $value): ExpressionBuilder<T> <= less than or equal
eq(mixed $value): ExpressionBuilder<T> == loose equality
neq(mixed $value): ExpressionBuilder<T> != loose inequality
same(mixed $value): ExpressionBuilder<T> === strict equality
notSame(mixed $value): ExpressionBuilder<T> !== strict inequality
isNull(): ExpressionBuilder<T> === with null value is null
isNotNull(): ExpressionBuilder<T> !== with null value is not null

A comparison produces BinaryExpression(<path>, <operator>, ConstantExpression($value, 'mixed'), 'bool'). The path nodes and the constant have the type 'mixed', because the builder does not know the property types. The generated closure reads the path from its argument and applies the same PHP operator.

Reading values along a path

Builder closures read each segment from whatever value the previous segment produced:

  • an array (including Tyhp structs, which compile to arrays) is read by key;
  • an object implementing \ArrayAccess is read by offset;
  • any other object is read as a property.

A strict segment throws \TypeError when the current value is null, when an array key or \ArrayAccess offset does not exist, or when the value is a scalar. A segment marked null-safe (->prop('city', nullSafe: true)) returns null in those cases instead.

Each null-safe flag covers only its own segment. Unlike PHP's ?->, a null-safe segment does not short-circuit the rest of the chain, so every segment that can see null needs its own flag:

<?php

// Safe when address is null: the city read is the hop that sees null.
$city = Expression::of(User::class)->prop('address')->prop('city', nullSafe: true);

// Throws \TypeError when address is null: only the address read is null-safe.
$strict = Expression::of(User::class)->prop('address', nullSafe: true)->prop('city');

Builder errors

Situation Exception and message
Comparison on Expression::of(X) with no property \LogicException: "Expression::of() requires at least one property before a comparison."
Property access or prop() on a completed ExpressionBuilder \LogicException: "Cannot access properties on a completed expression. Call and()/or() to continue, or start from Expression::of()."
Comparison on a completed ExpressionBuilder \LogicException: "Comparisons on ExpressionBuilder require and()/or() followed by a property path, or start from Expression::of()."
Comparison right after and() / or() with no property \LogicException: "Continue the property path after and()/or() before comparing."
and() / or() while a right-hand path is waiting for its comparison \LogicException: "Finish the property comparison before calling and()/or()."
and() / or() right after another and() / or() \LogicException: "Finish the right-hand side after and()/or() before combining further."
Assigning a property on a builder \BadMethodCallException from __set()
Calling a method of the source class on a builder \BadMethodCallException from __call()
Mixed or non-string segments passed to of() \InvalidArgumentException

An ExpressionBuilder that is waiting for its right-hand side is still an Expression, but its body and closure are only the left side. Finish the chain with a comparison before you hand it to code that reads the tree.

PropertyPathBuilder<T>

Tyhp\PropertyPathBuilder<T extends object> extends PropertyPath<callable(T): mixed>. It is the builder you get from Expression::of() while the chain contains only property access. Its $returnType is 'mixed', and its closure reads the path as described in Reading values along a path.

The ten comparison methods promote the path to an ExpressionBuilder<T>.

ExpressionBuilder<T>

Tyhp\ExpressionBuilder<T extends object> extends Expression<callable(T): bool>. You get one from a comparison on a PropertyPathBuilder, or from and() / or() on any Expression. Its $returnType is 'bool'.

The ten comparison methods complete a right-hand side. Called on a completed builder, they throw \LogicException.

Combining expressions

and(), or(), andAll(), and orAll() build a new BinaryExpression from the two bodies and keep the left expression's $parameters. The right body keeps its own ParameterExpression nodes. If the two sides were written with different parameter names — for example a compiler-built fn ($u) => … on the left and a builder-made right side, whose parameter is always called source — the combined tree contains both names. A translator should match parameters by index, which is the same on both sides.

Expression nodes

Every node extends Tyhp\Expression\ExpressionNode. All node classes except the abstract base are final, and all node properties are readonly.

ExpressionNode

Node summary

The "Produced by" column describes what the compiler emits for an fn passed to an Expression<…> parameter. The language page lists which constructs are allowed in a tree body.

Class nodeType Produced by Child nodes and data Visitor method
ParameterExpression parameter each use of a lambda parameter name, paramType, index visitParameter
PropertyAccessExpression propertyAccess $x->prop object, property visitPropertyAccess
NullSafeAccessExpression nullSafeAccess $x?->prop object, property visitNullSafeAccess
MethodCallExpression methodCall $x->method(...) object, method, arguments visitMethodCall
StaticMethodCallExpression staticMethodCall Cls::method(...) class, method, arguments visitStaticMethodCall
BinaryExpression binary arithmetic, string, comparison, and logical operators left, operator, right visitBinary
UnaryExpression unary !, -, +, ~ operator, operand, isPrefix visitUnary
ConstantExpression constant literals, captured variables, Cls::CONST, Cls::class, magic constants value visitConstant
TernaryExpression ternary $c ? $a : $b, $a ?: $b condition, ifTrue, ifFalse visitTernary
CoalesceExpression coalesce $a ?? $b left, right visitCoalesce
ArrayAccessExpression arrayAccess $x[$i] array, index visitArrayAccess
CastExpression cast (int), (string), (float), (bool), (array), (object), (decimal) targetType, operand visitCast
NewExpression new new Cls(...) class, arguments visitNew
InstanceofExpression instanceof $x instanceof T, $x is T operand, targetType visitInstanceof

Parentheses do not produce nodes. ($u->age + 1) * 2 is a BinaryExpression for * whose left child is the BinaryExpression for +.

ParameterExpression

PropertyAccessExpression

NullSafeAccessExpression

MethodCallExpression

StaticMethodCallExpression

BinaryExpression

UnaryExpression

ConstantExpression

TernaryExpression

CoalesceExpression

ArrayAccessExpression

CastExpression

NewExpression

InstanceofExpression

Walking and translating trees

ExpressionVisitor

Tyhp\Expression\ExpressionVisitor is an abstract base class for the visitor pattern. visit($node) calls $node->accept($this), and each node class calls back into its own method — visitBinary() for a BinaryExpression, and so on. Override the methods for the nodes you handle.

Two rules matter when you extend it:

  • Every default method returns null. A node type you did not override is silently translated to null. Check for null (or for the type you expect) and report unsupported nodes, as the SQL example below does.
  • The defaults do not recurse. A visitBinary() override must call $this->visit($node->left) and $this->visit($node->right) itself.
Method Called for
visitParameter(ParameterExpression $node): mixed ParameterExpression
visitPropertyAccess(PropertyAccessExpression $node): mixed PropertyAccessExpression
visitNullSafeAccess(NullSafeAccessExpression $node): mixed NullSafeAccessExpression
visitMethodCall(MethodCallExpression $node): mixed MethodCallExpression
visitStaticMethodCall(StaticMethodCallExpression $node): mixed StaticMethodCallExpression
visitBinary(BinaryExpression $node): mixed BinaryExpression
visitUnary(UnaryExpression $node): mixed UnaryExpression
visitConstant(ConstantExpression $node): mixed ConstantExpression
visitTernary(TernaryExpression $node): mixed TernaryExpression
visitCoalesce(CoalesceExpression $node): mixed CoalesceExpression
visitArrayAccess(ArrayAccessExpression $node): mixed ArrayAccessExpression
visitCast(CastExpression $node): mixed CastExpression
visitNew(NewExpression $node): mixed NewExpression
visitInstanceof(InstanceofExpression $node): mixed InstanceofExpression

All visitor methods are public and return mixed, so a visitor can produce any kind of result: SQL strings, a new tree, a validation rule, a boolean.

Walking every node

Sometimes you need every node rather than a translation: to collect the properties a predicate uses, to find captured values, or to print a tree while debugging. A small helper that lists each node's children, plus a recursive walk, covers all fourteen node types:

<?tyhp

namespace App;

use Tyhp\Expression;
use Tyhp\Expression\ArrayAccessExpression;
use Tyhp\Expression\BinaryExpression;
use Tyhp\Expression\CastExpression;
use Tyhp\Expression\CoalesceExpression;
use Tyhp\Expression\ExpressionNode;
use Tyhp\Expression\InstanceofExpression;
use Tyhp\Expression\MethodCallExpression;
use Tyhp\Expression\NewExpression;
use Tyhp\Expression\NullSafeAccessExpression;
use Tyhp\Expression\PropertyAccessExpression;
use Tyhp\Expression\StaticMethodCallExpression;
use Tyhp\Expression\TernaryExpression;
use Tyhp\Expression\UnaryExpression;

final class ExpressionTree {
    public static function children(ExpressionNode $node): array<ExpressionNode> {
        if ($node is BinaryExpression || $node is CoalesceExpression) {
            return [$node->left, $node->right];
        }
        if ($node is UnaryExpression || $node is CastExpression || $node is InstanceofExpression) {
            return [$node->operand];
        }
        if ($node is PropertyAccessExpression || $node is NullSafeAccessExpression) {
            return [$node->object];
        }
        if ($node is ArrayAccessExpression) {
            return [$node->array, $node->index];
        }
        if ($node is MethodCallExpression) {
            return [$node->object, ...$node->arguments];
        }
        if ($node is StaticMethodCallExpression || $node is NewExpression) {
            return $node->arguments;
        }
        if ($node is TernaryExpression) {
            array<ExpressionNode> $parts = [$node->condition];
            if ($node->ifTrue !== null) {
                $parts[] = $node->ifTrue;
            }
            $parts[] = $node->ifFalse;
            return $parts;
        }
        return [];   // ParameterExpression and ConstantExpression are leaves
    }

    public static function walk(ExpressionNode $node, callable(ExpressionNode, int): void $visit, int $depth = 0): void {
        $visit($node, $depth);
        foreach (self::children($node) as $child) {
            self::walk($child, $visit, $depth + 1);
        }
    }

    public static function referencedProperties(Expression $expression): array<string> {
        array<string> $names = [];
        self::walk($expression->body, function (ExpressionNode $node, int $depth) use (&$names): void {
            if ($node is PropertyAccessExpression || $node is NullSafeAccessExpression) {
                $names[] = $node->property;
            }
        });
        return \array_values(\array_unique($names));
    }
}

Printing a tree with walk():

<?tyhp

function dump(Expression<callable(User): bool> $predicate): void {
    ExpressionTree::walk($predicate->body, function (ExpressionNode $node, int $depth): void {
        echo \str_repeat('  ', $depth), $node->nodeType, ' : ', $node->type, "\n";
    });
}

int $minAge = 18;
dump(fn ($u) => $u->age >= $minAge && ($u->nickname ?? $u->name) !== 'root');
binary : bool
  binary : bool
    propertyAccess : int
      parameter : App\User
    constant : int
  binary : bool
    coalesce : string
      propertyAccess : ?string
        parameter : App\User
      propertyAccess : string
        parameter : App\User
    constant : string

Worked example: a SQL query builder

This example turns fn ($u) => $u->age >= $minAge && $u->isActive into a parameterized SQL WHERE clause. It has two parts: a visitor that translates boolean trees, and a generic Query<T> class whose methods accept Expression and PropertyPath parameters.

The translator handles comparisons, && / || (in both spellings), !, direct properties of the lambda parameter, and constants. Constants become ? placeholders with their values collected in $bindings, so captured values never end up in the SQL text. Anything else makes translate() throw.

<?tyhp

namespace App\Query;

use Tyhp\Expression\BinaryExpression;
use Tyhp\Expression\ConstantExpression;
use Tyhp\Expression\ExpressionNode;
use Tyhp\Expression\ExpressionVisitor;
use Tyhp\Expression\ParameterExpression;
use Tyhp\Expression\PropertyAccessExpression;
use Tyhp\Expression\UnaryExpression;

final class SqlWhereTranslator extends ExpressionVisitor {
    public array<mixed> $bindings = [];

    public function translate(ExpressionNode $node): string {
        mixed $sql = $this->visit($node);
        if (!($sql is string)) {
            throw new \InvalidArgumentException("Cannot translate '{$node->nodeType}' nodes to SQL.");
        }
        return $sql;
    }

    public function visitBinary(BinaryExpression $node): mixed {
        string $operator = match ($node->operator) {
            '&&', 'and' => 'AND',
            '||', 'or' => 'OR',
            '===', '==' => '=',
            '!==', '!=' => '<>',
            '>', '>=', '<', '<=' => $node->operator,
            default => throw new \InvalidArgumentException("Unsupported operator '{$node->operator}'."),
        };
        string $left = $this->translate($node->left);
        string $right = $this->translate($node->right);
        return "({$left} {$operator} {$right})";
    }

    public function visitUnary(UnaryExpression $node): mixed {
        if ($node->operator !== '!') {
            return null;
        }
        return 'NOT ' . $this->translate($node->operand);
    }

    public function visitPropertyAccess(PropertyAccessExpression $node): mixed {
        // Only direct columns ($u->age); nested paths ($u->address->city) are rejected.
        if (!($node->object is ParameterExpression)) {
            return null;
        }
        return $node->property;
    }

    public function visitConstant(ConstantExpression $node): mixed {
        $this->bindings[] = $node->value;
        return '?';
    }
}

The query class only reads ->body from the predicate and getPropertyName() from the sort column:

<?tyhp

namespace App\Query;

use Tyhp\Expression;
use Tyhp\PropertyPath;

final class Query<T extends object> {
    private array<string> $conditions = [];
    private array<mixed> $bindings = [];
    private array<string> $ordering = [];

    public function __construct(private string $table) {}

    public function where(Expression<callable(T): bool> $predicate): static {
        $translator = new SqlWhereTranslator();
        $this->conditions[] = $translator->translate($predicate->body);
        foreach ($translator->bindings as $value) {
            $this->bindings[] = $value;
        }
        return $this;
    }

    public function orderBy(PropertyPath<callable(T): mixed> $column, string $direction = 'ASC'): static {
        $this->ordering[] = $column->getPropertyName() . ' ' . $direction;
        return $this;
    }

    public function toSql(): string {
        string $sql = "SELECT * FROM {$this->table}";
        if ($this->conditions !== []) {
            $sql .= ' WHERE ' . \implode(' AND ', $this->conditions);
        }
        if ($this->ordering !== []) {
            $sql .= ' ORDER BY ' . \implode(', ', $this->ordering);
        }
        return $sql;
    }

    public function getBindings(): array<mixed> {
        return $this->bindings;
    }
}

Callers write ordinary, type-checked lambdas. A misspelled property is a compile error, and renaming User::$age in the IDE updates the query:

<?tyhp

use App\Query\Query;

int $minAge = 18;

$query = new Query<User>('users')
    ->where(fn ($u) => $u->age >= $minAge && $u->isActive)
    ->where(fn ($u) => $u->name !== 'root')
    ->orderBy(fn ($u) => $u->age, 'DESC');

echo $query->toSql();
// SELECT * FROM users WHERE ((age >= ?) AND isActive) AND (name <> ?) ORDER BY age DESC

$query->getBindings();
// [18, 'root']

PHP code can use the same compiled Query class by building the trees with the fluent builders. The builder trees have the same shape — a BinaryExpression over a property of the parameter and a ConstantExpression — so the translator needs no changes:

<?php

use App\Query\Query;
use App\User;
use Tyhp\Expression;

$query = (new Query('users'))
    ->where(Expression::of(User::class)->age->gte(18)->and()->isActive->same(true))
    ->orderBy(Expression::of(User::class)->name);

echo $query->toSql();
// SELECT * FROM users WHERE ((age >= ?) AND (isActive = ?)) ORDER BY name ASC

To grow this into a real query builder, map property names to column names, translate CoalesceExpression to COALESCE(...), turn a comparison with a null constant into IS NULL, and support nested paths through joins.

Serialization

ExpressionSerializer

Tyhp\Expression\ExpressionSerializer converts trees to JSON and compares trees structurally. Use it to log or debug a tree, to send a query description to another service, or to build a cache key for work derived from a tree (such as translated SQL).

JSON format

The document has three top-level keys:

  • parameters — an array with one object per parameter: name, index, and type (the parameter type).
  • returnType — the $returnType string.
  • body — the root node.

Every node object has nodeType and type, plus the fields for its class:

nodeType Additional fields
parameter name, index
propertyAccess object (node), property
nullSafeAccess object (node), property
methodCall object (node), method, arguments (array of nodes)
staticMethodCall class, method, arguments (array of nodes)
binary left (node), operator, right (node)
unary operator, operand (node), isPrefix
constant value (the JSON encoding of the value)
ternary condition (node), ifTrue (node, or null for ?:), ifFalse (node)
coalesce left (node), right (node)
arrayAccess array (node), index (node)
cast targetType, operand (node)
new class, arguments (array of nodes)
instanceof operand (node), targetType

Constant values are encoded by PHP's \json_encode(): scalars and null as JSON values, arrays as arrays or objects, and objects as an object of their public properties. Identify a node by its nodeType; the order of keys inside a node object is not significant.

This is the output for fn ($u) => $u->age >= $minAge && $u->isActive passed to Expression<callable(User): bool> with $minAge = 18:

{
    "parameters": [
        {
            "name": "u",
            "index": 0,
            "type": "App\\User"
        }
    ],
    "returnType": "bool",
    "body": {
        "nodeType": "binary",
        "type": "bool",
        "operator": "&&",
        "left": {
            "nodeType": "binary",
            "type": "bool",
            "operator": ">=",
            "left": {
                "nodeType": "propertyAccess",
                "type": "int",
                "property": "age",
                "object": {
                    "nodeType": "parameter",
                    "type": "App\\User",
                    "name": "u",
                    "index": 0
                }
            },
            "right": {
                "nodeType": "constant",
                "type": "int",
                "value": 18
            }
        },
        "right": {
            "nodeType": "propertyAccess",
            "type": "bool",
            "property": "isActive",
            "object": {
                "nodeType": "parameter",
                "type": "App\\User",
                "name": "u",
                "index": 0
            }
        }
    }
}

The same predicate built with Expression::of(User::class)->age->gte(18) has the parameter name source and the type "mixed" on the property and constant nodes.

The JSON is a description for other programs to read. The receiving side decodes it as data (for example with \json_decode()) and interprets each nodeType itself.

Serialized struct types

Tyhp\Expression also exports one struct type per node object in the JSON format. They describe the shape of the arrays the serializer builds before encoding. Every node object also carries the nodeType and type fields of SerializedExpression.

Struct Fields
SerializedExpression string $nodeType = '', string $type = ''
SerializedParameterExpression string $name = '', int $index = 0
SerializedPropertyAccessExpression SerializedExpression $object, string $property = ''
SerializedNullSafeAccessExpression SerializedExpression $object, string $property = ''
SerializedMethodCallExpression SerializedExpression $object, string $method = '', array<SerializedExpression> $arguments = []
SerializedStaticMethodCallExpression string $class = '', string $method = '', array<SerializedExpression> $arguments = []
SerializedBinaryExpression SerializedExpression $left, string $operator = '', SerializedExpression $right
SerializedUnaryExpression string $operator = '', SerializedExpression $operand, bool $isPrefix = true
SerializedConstantExpression mixed $value = null
SerializedTernaryExpression SerializedExpression $condition, ?SerializedExpression $ifTrue = null, SerializedExpression $ifFalse
SerializedCoalesceExpression SerializedExpression $left, SerializedExpression $right
SerializedArrayAccessExpression SerializedExpression $array, SerializedExpression $index
SerializedCastExpression string $targetType = '', SerializedExpression $operand
SerializedNewExpression string $class = '', array<SerializedExpression> $arguments = []
SerializedInstanceofExpression SerializedExpression $operand, string $targetType = ''

See Structs for how struct types work.

PathNode

Gotchas

Warning

Unhandled nodes become null. ExpressionVisitor's default methods return null and do not visit children. A translator that forgets a node type silently produces null for it. Check visitor results, as SqlWhereTranslator::translate() does, and recurse explicitly in your overrides.

Warning

Captured values are read when the tree is built. A captured variable becomes a ConstantExpression holding its value at the time of the call. If you store the tree and the variable changes later, the tree keeps the old value. Objects are held by reference, so changes to a captured object's properties are visible.

Warning

Handle both spellings of logical operators. $a and $b produces the operator 'and' and $a || $b produces '||'. Translators should accept '&&'/'and' and '||'/'or'.

Note

Compare parameters by index. Each use of a parameter in the body is a new ParameterExpression object, separate from the objects in $parameters. PropertyPath and builder trees name their parameter source, and combined trees can contain two different names for the same position. $index is the reliable key.

Note

equals() is literal. Two trees are equal only if their JSON is identical: same parameter names, same node types, same constant values. fn ($u) => … and fn ($x) => … with identical bodies are not equal.

Note

Node types are strings for display, not for parsing. Literal constants carry literal types ('18'), builder nodes use 'mixed', and class names have no leading backslash. Check ConstantExpression::$value with PHP's type functions, and use $nodeType or the node class to decide what a node is.

Note

Finish builder chains. After ->and() with no argument, the builder still looks like an Expression, but its body and closure describe only the left side until a comparison completes the right side.

Tip

Accept Expression even when you sometimes only need to run the code. Every tree carries its closure. You can call the expression like a function or pass it to a PHP callable parameter such as \array_filter()'s, so the same API serves both a database backend that translates the tree and an in-memory backend that just runs it.