Part of datalogic-rs: one engine, every runtime.
PHP bindings for datalogic-rs, the JSONLogic rules engine with one Rust core and official bindings for Rust, Node.js, the browser (WASM), Python, Go, Java, .NET, and PHP. Compile a rule once and evaluate it many times, natively in PHP. Same rules, same semantics: every binding runs the same core and passes the same 2,128-case conformance battery (66 suites).
For the cross-runtime overview and the API-tier model every binding implements, see the repo README.
New in v5. This package is new: there is no v4 PHP artifact. If you are coming from the v4 Rust crate or the v4
@goplasmatic/datalogicWASM package, see MIGRATION.md for the engine's v4 → v5 changes.
composer require goplasmatic/datalogicComposer records the constraint ^5.8. The package requires PHP 8.4+
with ext-ffi enabled. The binding is a PHP FFI wrapper over the
engine's C ABI; the Composer package ships the native library under
lib/<os>-<arch>/ for every supported platform, and the FFI loader
picks the one for the host at runtime. You need no Rust toolchain.
| Platform | Architectures |
|---|---|
| Linux | x86_64, aarch64 |
| macOS | x86_64, arm64 |
| Windows | x86_64, arm64 |
use Goplasmatic\Datalogic\Engine;
$engine = new Engine();
echo $engine->apply('{"+":[1,2]}', '{}'); // "3"Rules, data, and results cross the boundary as JSON strings; use
json_encode / json_decode at the edges.
Compile the rule once when you'll evaluate it against many data inputs:
$engine = new Engine();
$rule = $engine->compile('{"var":"x"}');
foreach ([1, 2, 3] as $x) {
echo $rule->evaluate(json_encode(['x' => $x])), "\n";
}Engine and compiled Rule objects carry no per-call state: build and
compile once per process and reuse them across requests. Sessions
(below) hold a mutable arena, so give each evaluation loop its own.
compile reads a rule in the engine's own mode. compileTemplate and
compileStrict choose templating for one compile, so one engine (with
one set of custom operators) can compile both conditions and output
templates. compileMode($rule, $mode) takes the mode as an int:
Native::MODE_ENGINE, Native::MODE_STRICT or Native::MODE_TEMPLATE
(Goplasmatic\Datalogic\Internal\Native).
$shape = $engine->compileTemplate('{"user": {"var": "name"}, "n": 1}');
echo $shape->evaluate('{"name": "ana"}'); // {"user":"ana","n":1}check($rule, $mode = Native::MODE_ENGINE) reports every problem the
engine can see before the rule runs, as a JSON array of {code, severity, message, pointer, operator}, where pointer is an RFC 6901
JSON Pointer into the rule. Errors are what will fail (an unknown
operator, with a suggestion one edit away; an argument count the
operator rejects; a timezone that does not exist); warnings run but are
probably mistakes. A bad rule does not make check throw.
compileChecked($rule) compiles only a rule with no error and otherwise
throws ParseException with $errorType === "CompileError", whose
$diagnosticsJson holds the same array:
use Goplasmatic\Datalogic\Exception\ParseException;
echo $engine->check('{"if": [{">": [{"var": "age"}, 17]}, "adult", {"vr": "x"}]}');
// [{"code":"UnknownOperator","severity":"error","message":"unknown operator `vr`; did you mean `var`?",
// "pointer":"/if/2","operator":"vr"}]
try {
$rule = $engine->compileChecked($ruleJson);
} catch (ParseException $e) {
echo $e->errorType; // "CompileError"
echo $e->diagnosticsJson; // every diagnostic, errors and warnings
}$rule->facts() returns, as JSON, the data paths the rule reads (each
as its segments), the operators it calls, and whether its result
depends only on its data:
$rule = $engine->compile(
'{"and": [{">=": [{"var": "user.age"}, 18]}, {"in": [{"var": "user.plan"}, ["pro", "team"]]}]}'
);
echo $rule->facts();
// {"reads":[["user","age"],["user","plan"]],"computed_reads":false,"reads_complete":true,
// "reads_data":true,"operators":[">=","and","in","val"],"custom_operators":[],
// "deterministic":true}reads_complete is false when the rule builds a path at runtime or
calls a custom operator, and deterministic is false for now and
for any custom operator. The facts describe the compiled rule, so a
branch the optimizer removed is neither read nor listed.
A Session reuses one arena across evaluations and resets it at the
start of every call, so peak memory stays bounded:
$session = $engine->openSession();
foreach ($inputs as $data) {
$result = $session->evaluate($rule, $data);
}PHP's destructor releases native handles when the wrapper object goes
out of scope; every wrapper type also exposes an explicit
close() for early release.
When one payload feeds many evaluations, parse it once into a
DataHandle and pass the handle to Rule::evaluate or
Session::evaluate in place of the JSON string, which skips the
per-call JSON parse:
use Goplasmatic\Datalogic\DataHandle;
$data = new DataHandle('{"user":{"age":42,"plan":"pro"}}');
$rule->evaluate($data); // same result as the string overload
$session->evaluate($rule, $data); // hot path: zero parse work per callHandles are immutable and engine-independent: one handle can feed
rules compiled by different engines, any number of times (evaluation
never consumes it). The binding releases the native memory when PHP
collects the object, or when you call close();
allocatedBytes() reports the handle's resident size.
Sessions can return native PHP scalars instead of JSON strings, which
suits predicates (feature flags, routing) where decoding JSON per call
is pure overhead. All four take a compiled Rule and a
DataHandle:
$session->evaluateBool($rule, $data); // bool (strict: JSON true/false only)
$session->evaluateInt($rule, $data); // int (exact integers only)
$session->evaluateFloat($rule, $data); // float (any JSON number)
$session->evaluateTruthy($rule, $data); // bool (JSONLogic truthiness, never mismatches)The strict variants throw EvaluateException with
$errorType === "TypeMismatch" when the result is of any other type;
evaluateTruthy collapses any result through the engine's configured
truthiness rules (the same coercion if/and/or apply).
$engine->truthy($valueJson) applies the same truthiness to a value
you already hold, so a host check agrees with the engine. The argument
is JSON text: $engine->truthy('[]') and $engine->truthy('{}') are
false under the default rules, and a configured truthy_evaluator
applies.
Evaluate one rule against many payloads (evaluateBatch) or many
rules against one payload (evaluateMany, the rule-set / feature-flag
shape) in a single native call. Results come back in input order; a
failed item puts a BatchItemError in its slot instead of aborting
the other N-1; item failures never throw:
use Goplasmatic\Datalogic\BatchItemError;
$results = $session->evaluateBatch($rule, $handles); // list<DataHandle> in
$results = $session->evaluateMany($rules, $data); // list<Rule> in
foreach ($results as $i => $r) {
if ($r instanceof BatchItemError) {
error_log("item {$i} failed: {$r->tag}: {$r->message}");
continue;
}
// $r is the item's JSON-string result
}BatchItemError exposes $status (the raw C-ABI status code), $tag
(stable engine tag, for example "Thrown", "TypeError",
"InvalidOperator"), $message, and $operator (innermost failing
operator, when known). An item whose error the binding cannot decode
reads as tag "InternalError" with the raw item JSON as its message.
The binding mirrors the Rust engine's
API tier model.
Rules and results cross the boundary as JSON strings, except where a
tier says otherwise: data can be a DataHandle, typed evaluations
return native PHP scalars, and batch calls return a list.
| Tier | Entry point | Use when |
|---|---|---|
| One-shot | $engine->apply($rule, $data) |
Ad-hoc evaluation, one rule + one data shape |
| Engine + config | new Engine($templating) / Engine::builder()…->build() |
Templating mode, custom operators, evaluation config |
| Compile once | $engine->compile($rule) → $rule->evaluate($data) |
Same rule evaluated against many data inputs |
| Parse once | new DataHandle($json) → pass instead of a JSON string |
Same payload evaluated by many rules/calls |
| Session | $engine->openSession() → $session->evaluate($rule, $data) |
Hot loops: amortise arena reset across iterations |
| Typed | $session->evaluateBool/Int/Float/Truthy($rule, $handle) |
Predicates: native scalars, no JSON decode per call |
| Batch | $session->evaluateBatch($rule, $handles) / evaluateMany($rules, $handle) |
Many evaluations per FFI crossing, per-item errors |
| Traced | $engine->openTracedSession() → $session->evaluate($rule, $data) |
Step-by-step debugging; feeds the React debugger |
| Metered | $session->evaluateMetered($rule, $data, $budget) → ['value' => …, 'ops' => …] |
What an evaluation costs, under an optional cap |
| Checked | $engine->check($rule, $mode) / $engine->compileChecked($rule) |
Reject a bad rule before it runs |
| Introspection | $engine->operators() / $rule->facts() / $engine->truthy($json) |
Tooling: operator catalogue, what a rule reads |
Rule::evaluate and Session::evaluate accept either a JSON string or
a DataHandle.
Register PHP-implemented operators through the builder. Each callback
receives the operator's pre-evaluated arguments as a JSON-array string
and returns a JSON-value string. If the callback throws, the evaluation
fails with error type "Custom" and a message that names the operator
and carries yours.
$engine = Engine::builder()
->addOperator('double', function (string $argsJson): string {
$args = json_decode($argsJson, true);
return (string) ((int) $args[0] * 2);
})
->build();
echo $engine->apply('{"double":[21]}', '{}'); // "42"Built-ins win: registering a name that one of the engine's built-in
operators answers to (+, if, var, an alias such as ?:) has no
effect at evaluation time; the built-in runs. Call
withStrictOperatorNames(true) before addOperator to make such a
registration throw EvaluateException ($errorType === "ConfigurationError") instead:
Engine::builder()
->withStrictOperatorNames(true)
->addOperator('length', fn (string $args): string => '0'); // throws: `length` is a built-inA name from an operator family the engine leaves out (see Operator families) is free for a custom operator.
Engine::builder()->setConfigJson($json) sets the evaluation semantics
from a JSON object string: an optional preset plus per-field
overrides. An unknown key or value makes setConfigJson throw
EvaluateException (error type ConfigurationError), so a typo fails
before the engine exists:
$lenient = Engine::builder()
->setConfigJson('{"division_by_zero":"return_null"}')
->build();
echo $lenient->apply('{"/":[1.5,0]}', '{}'); // "null"
$strict = Engine::builder()
->setConfigJson('{"preset":"strict"}')
->build();
$strict->apply('{"+":["",1]}', '{}'); // throws: strict rejects non-numeric coercion| Key | Values |
|---|---|
preset |
"default", "safe_arithmetic", "strict" |
arithmetic_nan_handling |
"throw_error", "ignore_value", "coerce_to_zero", "return_null" |
division_by_zero |
"return_saturated", "throw_error", "return_null", "return_infinity" |
loose_equality_errors |
bool |
missing_var |
"null" (default: a missing variable reads as null), "error" (raises VariableNotFound) |
truthy_evaluator |
"javascript", "python", "strict_boolean" |
numeric_coercion |
object of bools: empty_string_to_zero, null_to_zero, bool_to_number, reject_non_numeric |
max_recursion_depth |
integer >= 1 |
ops_budget |
integer >= 1, or null for unbounded (caps the work one evaluation may do; crossing it raises BudgetExceeded) |
The preset applies first; the remaining keys override individual
fields on top of it. Every binding shares this JSON schema and parses it
with the same core code, so a config that works here works in the
Python, Node, and WASM bindings too. The Rust crate's
EvaluationConfig
documents the full semantics of each knob.
With "missing_var": "error", a var / val read that finds nothing
throws EvaluateException with $errorType === "VariableNotFound"
naming the path, so a typo in a path fails instead of evaluating as
null. A default ({"var": ["x", 0]}), a present null, missing,
missing_some and exists are not misses, and try in the rule
catches the error.
The builder also takes withTemplating(bool),
withTemplateKeyEscape(string) (a one-character prefix that marks a
template key as an output field: with '$', {"$type": ...} emits the
key type), withStrictOperatorNames(bool) (see
Custom operators) and withFamilies(string ...)
(below).
$session->evaluateMetered($rule, $dataJson, $budget = 0) returns
['value' => string, 'ops' => int]: the result JSON and the operations
the evaluation charged. A $budget of 0 uses the engine's
ops_budget (unbounded if it has none); any other value caps that one
call.
$m = $engine->openSession()->evaluateMetered($rule, '{"xs": [1, 2, 3]}');
echo $m['value'], ' cost ', $m['ops'], " operations\n";One operation is one node the engine dispatches, one item an iterator
walks, or what an operator charges for the data it moves. Crossing the
budget throws EvaluateException with $errorType === "BudgetExceeded"
before the work is done, and a try in the rule cannot catch it.
$engine->operators() returns the operator catalogue as a JSON array,
one object per built-in operator the engine evaluates (name,
aliases, family, feature, min_args, max_args,
reads_context, effect, cost, scoped_arg), in the schema of
operators.json.
withFamilies(...) keeps an engine to the JSONLogic core plus the
families you name: ExtString, ExtArray, ExtObject, ExtControl,
ExtMath, ErrorHandling, DateTime, Tensor, Flagd. By default an
engine has every family. A name from a family left out compiles as an
unknown operator, which check reports and evaluation rejects with
$errorType === "InvalidOperator":
$strings = Engine::builder()->withFamilies('ExtString')->build();
echo $strings->apply('{"length": "abc"}', 'null'); // 3
$strings->apply('{"now": []}', 'null'); // throws: InvalidOperatorAn unknown family name throws EvaluateException with
$errorType === "ConfigurationError". With strict operator names on,
call withFamilies before addOperator.
Engine errors are thrown as subclasses of
Goplasmatic\Datalogic\Exception\DatalogicException (a
RuntimeException):
| Exception | When |
|---|---|
ParseException |
Malformed rule or data JSON ("ParseError"), or a rule compileChecked refused ("CompileError") |
EvaluateException |
Everything else: a failure while evaluating, an unknown operator ("InvalidOperator", also raised at compile for a multi-key object outside templating), a rejected config or builder option ("ConfigurationError"), a typed-result mismatch ("TypeMismatch"), a rule from a different engine ("InvalidArgument") |
Misuse of the PHP API throws the standard exceptions: a
RuntimeException for a closed handle or a builder that already
built, and an InvalidArgumentException for a bad argument.
The structured fields ride on the base class as public readonly
properties: $errorType is the stable engine tag (for example
"Thrown", "TypeError", "VariableNotFound", "BudgetExceeded", or
the binding-level "TypeMismatch"), $operatorName the innermost
failing operator (custom operators included), $pathJson the
root-to-leaf error path as a JSON array, and $nodeIdsJson the
compiled-node ids from leaf to root; each is null when not
applicable. $diagnosticsJson is set only for "CompileError".
Arithmetic NaN surfaces as $errorType === "Thrown" with a message
carrying {"type":"NaN"}; there is no "NaN" tag.
use Goplasmatic\Datalogic\Exception\EvaluateException;
try {
$engine->apply('{"+":["x",1]}', '{}'); // arithmetic on a non-numeric string throws {"type":"NaN"}
} catch (EvaluateException $e) {
echo $e->errorType; // "Thrown"
echo $e->operatorName; // "+"
echo $e->pathJson; // JSON-array path through the compiled tree
}The binding targets the engine's C ABI v2.1: every fallible native
call returns a status code plus an owned error handle. The first time
the binding loads the library (at server start when you preload it) it
checks datalogic_abi_version() == 2 and datalogic_abi_minor() >= 1,
and throws a RuntimeException naming both versions for a stale
library.
| Type | Pattern |
|---|---|
Engine |
Build once per process; reuse across requests |
Rule |
Compile once per process; reuse across requests |
DataHandle |
Immutable; share freely across engines/rules |
Session |
One per evaluation loop; do not share |
PHP is single-threaded per request, so Engine, Rule, Session, and
TracedSession are all safe in that model.
Custom operators use PHP FFI's auto-coercion of PHP callables to C
function pointers. The engine keeps the callables, and every Rule,
Session and TracedSession keeps its engine, so custom operators
keep working after $engine->close() for as long as something
compiled or opened from it lives.
Each native handle has one owner. Since 5.8, passing a handle a wrapper
already owns to another wrapper's constructor (new Rule($rule->handle()))
throws InvalidArgumentException instead of freeing the handle twice
later.
$session = $engine->openTracedSession();
$run = $session->evaluate('{"+":[{"var":"x"},1]}', '{"x":41}');
echo $run->result; // 42
echo count($run->steps); // number of execution steps
echo $run->pointers['3']; // /+/1: node 3 is the literal 1Same trace envelope as every other binding; the
React debugger
reads it as is. TracedRun exposes $result, $expressionTree,
$steps, $error, $structuredError and $pointers (plus
isSuccess()); runtime failures surface inside the run rather than as
exceptions. $pointers maps each node id to the RFC 6901 JSON Pointer
of the rule value that node was compiled from, so a debugger can place
each step in the rule as written; it is empty when the rule does not
compile. $session->evaluate($rule, $data, $mode) compiles the rule in
a mode (Native::MODE_TEMPLATE, ...), so a template traces as one.
Tracing disables the optimizer so every operator appears in the trace:
use it for debugging, not hot paths.
Geomean across 51 operator benchmark suites (Apple M2 Pro, median of 3 runs; pairwise shared-suite ratios per the methodology): the native Rust core evaluates at 10.3 ns/op, 7.0× faster than json-logic-engine (compiled, the fastest JS engine), 28.1× faster than jsonlogic-rs (the closest Rust alternative), and 83.6× faster than the json-logic-js reference implementation. The WASM build under Node measures 900.5 ns geomean (88× native); on Node servers, prefer @goplasmatic/datalogic-node.
The PHP FFI boundary adds a small per-call marshalling cost on top of the core numbers.
By default the binding calls FFI::cdef on first use, which works on
the CLI with no extra configuration. Production FPM/web SAPIs should use
FFI preloading
instead: PHP's default ffi.enable=preload forbids runtime FFI::cdef
outside the CLI, and preloading also moves all header parsing to server
start. The package ships a ready-made preload script:
; php.ini
opcache.preload=/path/to/vendor/goplasmatic/datalogic/preload.php
opcache.preload_user=www-data
ffi.enable=preloadpreload.php resolves the native library exactly like the runtime
loader does (see Building from source),
rewrites the FFI_LIB line of the bundled header
(src/datalogic-ffi.h) to that absolute path, and registers the
persistent FFI scope "datalogic" via FFI::load. At request time the
binding finds the scope through FFI::scope("datalogic") and skips
FFI::cdef entirely; when no scope is preloaded it falls back to
FFI::cdef. To pin a specific library build, set the
DATALOGIC_NATIVE_LIB environment variable before the server starts.
If your application already has a preload script, require the
package's preload.php from it (it is idempotent), or call
\Goplasmatic\Datalogic\Internal\Native::preload() yourself.
If you manage your own headers, you can copy src/datalogic-ffi.h,
hard-code FFI_LIB to your library path, and FFI::load it
yourself; the committed default (libdatalogic_c.so) resolves via
the OS loader path. The header is also the single source of the cdef
declarations (Native.php reads it with the #define lines stripped),
so the two load paths cannot drift apart.
The binding lives in
bindings/php/.
The FFI loader searches for the cdylib in order: the
DATALOGIC_NATIVE_LIB env var, the package's lib/<os>-<arch>/ layout,
the in-tree C ABI target dir, then the OS's default loader paths. A
fresh clone needs the C ABI built once:
git clone https://github.com/GoPlasmatic/datalogic-rs
cd datalogic-rs/bindings/c && cargo build --release
cd ../php
composer install
vendor/bin/phpunit- datalogic-rs repository
- Rust crate deep-dive
- PHP docs chapter
- Online playground
- JSONLogic specification
- C ABI internals
Apache-2.0. See the main repository for source and contribution guidelines.