Skip to content

polyspec/fsm-php ​

한국어

The PHP consumer of fsm (docs/plans/fsm-v1.md, C-2). The package is private and is a test bed: it is not published. It shows how the generated PHP code of a fsm definition is used, and it checks that code against the conformance vectors. The test bench generates the namespace Polyspec\FsmTest; your project chooses its own namespace with --php-namespace, and each machine is a sub-namespace <ns>\<Pascal machine key>, such as Polyspec\FsmTest\Order.

Generate into your project ​

Generate into the source directory of your package, with the namespace of your code. composer.json must autoload that directory with PSR-4, and the loader src/functions.php through autoload.files. The loader requires the functions.php of every machine, so the one files entry loads the functions of all machines:

sh
fsm-gen gen --project /path/to/order-machines --kind php --php-namespace 'Acme\Fsm' --out ./src
text
[fsm-gen] gen: ok

The autoload block of packages/php/composer.json is:

json
{
    "autoload": {
        "psr-4": {
            "Polyspec\\FsmTest\\": "src/"
        },
        "files": [
            "src/functions.php"
        ]
    }
}

The test bed's namespace is Polyspec\FsmTest. For the namespace of this README (Acme\Fsm), your composer.json has "psr-4": { "Acme\\Fsm\\": "src/" } and the same files entry, src/functions.php. Run composer dump-autoload once after you write composer.json; the generated files need no new dump.

Run the command again after every change of the definition. The vectors come from fsm-gen vectors --kind php --php-namespace 'Acme\Fsm' --out <file>, written into a test file of your own (the test bed writes tests/ConformanceTest.php).

Generated files ​

FileContent
src/Fsm/Code.phpthe enum Code (backed by string): Conflict = 'E_CONFLICT', InvalidTransition, UnknownCode, ReservedCode
src/Fsm/Error.phpthe exception Error of the shared namespace
src/Order/State.php, src/Order/Event.phpthe backed enums State: int (the value is the code) and Event: int (the position, from 1)
src/Order/Step.phpthe final class Step with the readonly properties to and changed
src/Order/functions.phpthe functions of the machine, in the namespace <ns>\Order; the loader requires it
src/functions.phpthe loader: it requires the functions.php of every machine (src/InReview/functions.php, src/Order/functions.php and so on); composer.json names this file in autoload.files

Each file starts with // Code generated by fsm-gen from <machine>; DO NOT EDIT. and declares strict_types.

API ​

The functions of the namespace Polyspec\FsmTest\Order (src/Order/functions.php) take the enum cases:

FunctionResult
initial(): Statethe initial state
code(State $state): intthe code of a state
key(State $state): stringthe key of a state, such as draft
fromCode(mixed $value): Statethe state of a code; a value that is not an int, or that is no state, throws E_UNKNOWN_CODE
isFinal(State $state): boolwhether a state is final
eventsFrom(State $state): arraythe events that have an edge from a state, in declaration order
target(State $expected, Event $event): Statethe state that the event leads to; throws E_INVALID_TRANSITION when it has no edge
apply(State $expected, Event $event, State $actual): Stepthe result of one attempt (see the root README, Transitions)

The persistence example examples/persist.php holds the complete apply use inside a transaction; it is the pattern to copy (see Persistence). The pure call for a value that is not in a database:

php
<?php
use Acme\Fsm\Order\State;
use Acme\Fsm\Order\Event;
use Acme\Fsm\Order;
use Acme\Fsm\Fsm\Error;

$step = Order\apply(State::Draft, Event::Next, State::Draft);
echo $step->to->name, ' ', var_export($step->changed, true), "\n";
try {
    Order\apply(State::Draft, Event::Next, State::Shipped);
} catch (Error $error) {
    echo $error->getCode(), ' ', $error->expected, ' ', $error->actual, ' ', $error->event, "\n";
}

This program was run in a temporary project with composer dump-autoload and the generated files of this checkout. Its output:

text
Paid true
E_CONFLICT 1 3 1

Errors ​

The functions throw Polyspec\FsmTest\Fsm\Error (here Acme\Fsm\Fsm\Error), an \Exception:

MemberValue
getCode()the code string: E_CONFLICT, E_INVALID_TRANSITION, E_UNKNOWN_CODE or E_RESERVED_CODE. It is the value of the enum Code, because PHP keeps the protected $code of Exception as an int
getMessage()the message, which names the machine
expectedthe expected state code (?int), or null when the error has none
actualthe stored state code (?int), or null
eventthe event position (?int, from 1), or null

Catch Error and read getCode() and the three properties.

Which properties a code sets is the rule of spec B-6 (docs/spec/fsm.md), and the vectors of tests/ConformanceTest.php assert it:

Codeexpectedactualevent
E_CONFLICTsetsetset
E_INVALID_TRANSITIONsetnullset
E_UNKNOWN_CODE, E_RESERVED_CODEnullnullnull

Persistence ​

examples/persist.php is the pattern of docs/spec/fsm.md, B-4 with polyspec orm on SQLite, PostgreSQL and MySQL (the DSN given to open selects the dialect). The stored state is a code in a row. A transition runs in one transaction: the row is read under its lock (forUpdate()), the event is applied to the state that the caller observed, and the row is updated only when the step is changed:

php
// packages/php/examples/persist.php, transition
function transition(Db $db, string $module, int $id, \UnitEnum $expected, \UnitEnum $event): object
{
    return $db->transaction(static function () use ($db, $module, $id, $expected, $event): object {
        $row = (new Item())->connect($db)->id($id)->forUpdate()->get();
        $apply = $module . '\apply';
        $fromCode = $module . '\fromCode';
        $code = $module . '\code';
        $step = $apply($expected, $event, $fromCode($row->getStatus()));
        if ($step->changed) {
            $row->setStatus($code($step->to))->update();
        }
        return $step;
    });
}

$module is the namespace of the machine (such as Polyspec\FsmTest\Order), so the same function serves every machine. An error of apply is thrown inside the transaction and rolls it back. A retry with the same $expected returns an unchanged step and writes nothing. open, seed and statusOf in the same file create the tables and read a row. The model Item is generated by orm-gen from contracts/schema/persist.dbs into examples/model/, and examples/model/bootstrap.php is loaded when a database is opened.

Tests ​

From the checkout:

sh
make -C packages/php install   # composer install: the pinned orm release and PHPUnit 13.4.2 (composer.lock)
make -C packages/php test      # PHPUnit through kit's run-tests
make test-php                      # the same from the root
make php-persist-test-postgres    # the persist cases on PostgreSQL (FSM_TEST_PG_DSN; a missing DSN exits 2)
make php-persist-test-mysql       # the persist cases on MySQL (FSM_TEST_MYSQL_DSN)

The run passes 89 of 89 cases. tests/ConformanceTest.php, generated by fsm-gen vectors --kind php, runs its one PHPUnit test testConformance on every vector case, a data set named <feature>__<id>, and each runs the check of the hand-written tests/ConformanceCase.php; the persist cases run examples/persist.php on SQLite, and make php-persist-test-postgres and make php-persist-test-mysql run them on PostgreSQL and MySQL. The root make gen-check checks that the generated files are the ones fsm-gen writes today. The PHP release of the checkout is 8.5 (config/toolchain.json); config.platform.php in composer.json is 8.5.0, the PHP pin of kit at the precision of composerPlatforms (config/dependency-policy.json), and require.php is >=8.5.