Search by

souther-lang / php-runtime

kawasima

What every PHP binding of a Souther library built by souther-native-compiler runs on: loading the library, a run's arena, handles, and failures

Package info

github.com/souther-lang/php-runtime

Homepage

pkg:composer/souther-lang/php-runtime

Statistics

Installs: 11

Dependents: 0

Suggesters: 0

Stars: 0

v0.1.0 2026-10-15 14:40 UTC

This package is auto-updated.

Last update: 2026-10-15 21:29:25 UTC


README

This package is what every PHP binding of a Souther library built by souther-native-compiler runs on. A binding is PHP generated from the library's manifest, and it calls the shared library through PHP's FFI; this package loads the library, keeps each run's scope of its arena, holds every value to the run it belongs to, and says what each failure is. It needs PHP 8.2 or later with ext-ffi, and raoh-php, whose Result, Decoder and issues a binding answers in. How the library itself is called, function by function, is in the host ABI, and how a binding is generated from the manifest is in writing a binding.

Generating a binding and reaching the runtime

A binding is written by the same command that builds the library, and the namespace it is written under is the caller's to name:

souther compile --target native --library build/native \
    --binding php build/php --binding-option 'namespace=Acme\Shop' model

--library writes the shared library, its C declarations and its manifest into build/native, and --binding php writes the binding into build/php, under the namespace its option namespace names, with a copy of those declarations as souther.ffi.h beside it. The top-level README says how the command is installed, and how a clone runs it. A namespace PHP will not take is refused before the library is built.

The runtime is the Composer package souther-lang/php-runtime on Packagist, which an application requires beside the binding it maps:

{
    "require": {
        "souther-lang/php-runtime": "^0.1"
    },
    "autoload": { "psr-4": { "Acme\\Shop\\": "build/php/" } }
}

The binding is mapped by the application like its own classes, or loaded with the autoload.php written beside it. The application then loads the library, libsouther.so or libsouther.dylib in the --library directory, and makes its calls inside a run:

$binding = \Acme\Shop\Binding::load(__DIR__ . '/build/native/libsouther.so');
echo $binding->run(fn () => \Acme\Shop\Cart\Lines\Behaviors::total($line));

scripts/php-from-the-command-line.sh does all of this in CI, with no Java calling the API. examples/php-cart is an application built this way: examples/cart-model, the cart model every host's cart example runs, with its HTTP boundary decoded by raoh-php and its injected behaviors implemented over PDO. Its README says how to build and run it, and what differs from the Java raoh-souther example. scripts/php-cart-example.sh builds it and runs its tests in CI.

What the binding is made of

A binding is written under the namespace the command was given, so two libraries publishing a module of the same name can stand in one application. A module is a namespace under it, each part of its name with its first letter made capital (shop is Acme\Shop\Shop, cart.lines is Acme\Shop\Cart\Lines). A product, a newtype and a unit are each a final readonly class holding the value where the library made it, with a reader for each field, a static of building one and answering a raoh-php Result, a static decode reading one out of its external form, and encode writing it. decoder is a raoh-php Decoder over a PHP value, which a host composes with its own the way a JVM host composes a type's decoder(): what the library finds wrong is an issue at the path the decoder was reached at. A PHP array is an ordered map, and a list is the one keyed by its indices, so an empty array is an empty object and an empty list at once. The value is therefore not written as text, which would have to say which: it is written with every array as an object and read by the library's reading of a host's value, which takes an array keyed by its indices as a list wherever the declaration holds one. A float stays one, so 1.0 handed where an Int is taken is refused rather than read as 1.

A sum is an interface, which a sum whose cases are all its cases extends, and each case's class implements it. <Sum>Codec finds which class a value is through the sum's case function, and reads and writes the sum's own external form, which says which case it is, with a decoder of its own. A case the model keeps, or a sum whose cases the library cannot tell apart, is <Sum>Value, which is still the sum and can still be written.

A module's behaviors are static functions on Behaviors, and its values are static functions on Values. A behavior is also a class named after it (quote is Quote), which an application holds the way the JVM backend's does. One a host implements is abstract, with an apply typed as the model says, and an application extends it. One the library defines is final: bind takes an instance of the class of each behavior it requires, in that order, each parameter named after the behavior, or by its place ($dependency0) where two of one name from two modules are both required. of makes one that requires nothing. apply calls it with the capabilities of what it was bound to, and the class is callable, so ($placeOrder)($orderId, $userId, $orderer) is the same call. It answers a value of the caller's run, as every function does, and opens no run of its own, whose values would be gone by the time the caller held them. A missing or mistyped implementation is PHP's TypeError at bind, not an UnboundInjection at the call. A behavior bound to another holds that one as it was bound, so one behavior bound to two implementations of another at two places calls each at its own place, as the JVM's does. A class is what the binding adds beside the model's surface, under a name the binding makes, so it is never a reason to refuse one: a behavior whose class PHP will not take (clone), or whose class would be one with another the module's binding writes (behaviors, or lookupCodec beside LookupCodec), has no class, and neither has what requires it. Each stays a function on Behaviors.

How values cross

How each value crosses is the shape the manifest says for it, and the binding decides only how PHP holds what crosses in that shape. An Int, a Bool and a String are PHP's own int, bool and string, and a value of a declared type is an object of its class.

Text is checked to be UTF-8 before it is handed over, and a PHP string that is not is an \InvalidArgumentException. The library puts what it takes in NFC by the Unicode version the language names, whichever ICU PHP was built with, so a string read back may differ in its bytes from the one handed over. Text whose NFC form is longer than a String holds is refused by the library, and the binding throws that refusal as a SoutherAbort naming REQUIRED_FORM_HAS_NO_PLACE.

A Decimal is a Souther\Runtime\Decimal both ways: its integer as a string of digits and its scale as an int, the two it is made of, since no type of PHP's own keeps a scale below nought. The scale is kept as it was, so 1.50 is 150 at scale 2 and 1.5 is 15 at scale 1. What PHP does with them, a BcMath\Number, text, a money library, is the application's. A Date, a Time, a DateTime and an Instant are Souther\Runtime\Date and the classes of the other three names, held as their numbers: a year, month and day; an hour, minute and second; a date and a time; the second from the epoch and the nanosecond. Each is checked where PHP makes it against what the type holds, and a constructor handed numbers that name no such value throws \InvalidArgumentException. Each crosses to the library as those numbers, each a 64-bit integer, and never as text, and the library answers whether they name a value. The runtime has already held them to that, so a refusal there is the runtime and the library disagreeing and is a \LogicException; the same holds for a Decimal. The text java.time writes for each is only for PHP to show, through __toString. None of them is a \DateTimeInterface, a moment in a zone, which only an Instant is and which does not reach every year one does; Date::of and Instant::of take one, and Instant::toDateTime gives one, to the microsecond.

A list is a PHP list both ways, typed array for PHP and list<T> in the docblock for PHPStan. An element is handed over as a value of its type is anywhere else, in the run the list is built in, so an array with a key out of order is an \InvalidArgumentException and an element of another type a TypeError, both before the library is called. A list read is copied into a PHP array when it is read, each element held as a field's value is. A list is built and read through the functions the module offers for a list of its element, and a function handing a list that the module offers no way to build or read is not written. A tuple is a PHP list of its members, typed array for PHP and array{0: T0, 1: T1} in the docblock. A set is the list of its members and a map the list of its entries, each a tuple of the key and the value: a Map<String, Int> is [['classic', 1], ['sf', 1]] both ways, not an array keyed by the map's keys. The library makes a set of what it is handed, two members that are one being one, and a map of the entries, the later of two under one key winning. What it hands back is in the order it holds them, which the language says nothing of; written in the external form (encode), a set's members and a map's keys are in ascending order, as the coverage document says. An optional is null where it holds nothing and what it holds where it holds something, except where what it holds may itself be null: an optional of an optional holds its value in a Souther\Runtime\Some, so Int?? is null, Some(null) or Some(1), and holding nothing is told apart at each depth.

A function value is a Closure, typed \Closure(T): R in the docblock. One the library answered is called in the innermost run going when it is called, and is refused, as any value is, once the run it was answered in has ended. A closure PHP hands over where a function value is taken is made into one through a slot the binding keeps for each function type, made once for the binding for the reason an implementation's is, and the closure is kept for as long as the run it was handed over in: what it throws comes back out of the call into the library that reached it.

A union no declaration names crosses in two places. PHP hands one over wherever the model takes it, as a parameter typed with the PHP union of its members' types, a declared member as its object and a primitive member as PHP's own type. PHP is handed one only as a behavior's answer, which says which case the value is: the behavior answers the PHP union of its members' types (Found|Missing), each value made as the class of the case the library says it is, or, for a case with no class of its own, through the codec of the member sum it is a case of. Nothing is generated for the union itself, which has no name in the model. A host implementing a behavior that answers one hands back a value of one of those types as it is.

What is left out, and what is refused

What a host has no way to reach is not written: a behavior, a value or a field the manifest says nothing reaches, whatever the reason it gives, and what PHP has no way to hold in the shape it crosses in. That is a value of a declared type the binding has no class for, and a union no declaration names anywhere PHP would be handed one other than a behavior's answer, including one inside a tuple, a list, an optional or a function value.

A name the model gives that PHP will not take is refused with the name, rather than spelt some other way, and the binding is not written: a reserved word, this or a superglobal for a parameter, two parameters of one function under one name, a field named as a method the binding writes, and two names that are one where they are looked up. A name the binding makes for what it adds, a behavior's class or what bind and an implementation's apply take, is never refused: it is made another way, or the class is left out. Two methods are one where they differ in the case of ASCII letters, as PHP compares them. Two classes or namespaces are one where they differ in the case of any letter, since each is also a file or a directory, and the file systems macOS and Windows use by default do not tell those apart.

The binding's directory is replaced whole on every build. The command writes it beside where it goes and puts it there once every binding asked for has been written, so it is the binding of one manifest: a class the model no longer declares does not survive a build, and a refused build leaves the directory as it was. The directory carries a .souther-binding file naming the generator that wrote it. A directory without one, holding anything, is refused rather than replaced, but one with it is replaced whole, so a file an application puts in it is gone after the next build.

Runs and the values in them

A host calls $binding->run(fn () => ...): the run opens a scope of the library's arena, and when it ends it expires the run's session and closes the scope. The library closes a scope only as the innermost one open on the calling thread, which runs always are, since they nest as calls do. No function of a binding takes a session. Each finds the innermost run going on this fiber of a library the binding was loaded for, and one called outside any run throws OutsideAnyRun. There is nothing for a caller to choose there: a computation belongs to the innermost run of its library, and a library's runs are on one fiber at a time. A decoder holds no run, so it can be made once and kept, and it finds the run it reads in when it is used.

Every value holds a handle to the session it was made in, and every read of one goes through the handle, which refuses a value whose run has ended (Expired) or that another library made (ForeignHandle) before anything reads the memory. A value that has to outlive its run leaves it as its external form, through encode. Runs nest, and a value from an outer run may be handed to a call in an inner one. What a computation (a construction, a reading, a behavior, a published value) answers is made in the scope of the innermost run going, and belongs to it. What a field reader answers is a value the one read already held, made no later, so it belongs to that value's run, whichever run it is read in. What an implementation is handed belongs to the innermost run, which is no longer than it lives.

Failures

A construction that does not hold its type's invariants is an Err with the issue the type's decode answers for the same value: invalid_format with its pattern for a clause that is a pattern, and invariant_violation naming the module, the type and the clause for one no constraint states. A reading answers the issues the library found, their codes being Raoh's already, or invalid_format where the text is not JSON. A Souther computation that ends without a value throws SoutherAbort, naming the status. What the runtime throws about runs, handles and the library's statuses implements Souther\Runtime\SoutherFailure; a host's own exception coming back out of a call does not, and neither do the refusals of a value PHP made (\InvalidArgumentException, TypeError) or of a library at loading, described below.

A behavior a host implements is handed to a run as its module's Injections::of(name: fn (...) => ...), which is what a behavior called through Behaviors in that run is constructed from, or bound to a behavior class as an instance of its own. Each behavior a host implements is one C function pointer, made once for each library a binding is loaded for, and each implementation handed over is a capability of it and a number of its own, so a worker does not grow with every request. What turns what the library hands an implementation into the binding's classes is the binding's, and an implementation keeps the binding it was written against: one library loaded by bindings generated under two namespaces calls each implementation through its own binding's, and the library itself holds nothing of either. A call reaching what nothing was handed for throws UnboundInjection. An exception an implementation throws is the one that comes back out of the call that reached it, and an implementation answering a value of another type than the behavior answers comes back as a TypeError.

Loading the library

The runtime loads a library once per process, told apart by device and inode rather than by the path it was loaded through, since two instances over one file would be two stacks of runs over one arena. Binding::load($library) reads the declarations copied beside the binding with FFI::cdef, and a second path to them may be passed where they stand elsewhere. Under ffi.enable=preload, where a request cannot declare a library itself, a preload script hands FFI::load() what Binding::preloadHeader($scope, $library) answers, and a request calls Binding::preloaded($scope, $library), given the library's path again so that it is the same library a load of that file would be.

Before anything else, the runtime asks the library souther_abi_generation() and refuses one that answers another generation than the one it calls, or none, with UnsupportedGeneration. A binding says which version of the runtime's surface it was generated for, and refuses to load over a runtime that says another (Souther\Runtime\Binding::PROTOCOL, 11 at present) with a \LogicException naming both. A binding whose numbering of the library's statuses differs from the declarations it loads, or from another binding's that loaded the same library first, is refused the same way.

The arena is per thread, and a handle is PHP's, which a ZTS runtime such as FrankenPHP does not hand from one thread to another; nothing here checks for one that was. A fiber is checked for: runs are one stack, ended in the order they nest, so while a run is going on one fiber, another fiber can neither start one nor use a value of it (RunOnAnotherFiber), a call made there finds no run of its own (OutsideAnyRun), and one suspended in a run holds the library until it ends that run.