byjesper/laravel-decision-support

Read-only, DB-backed decision-support guide engine for Laravel (graph-first, resumable evaluator, Mermaid).

Maintainers

Package info

github.com/byjesper/laravel-decision-support

pkg:composer/byjesper/laravel-decision-support

Transparency log

Statistics

Installs: 208

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.4.0 2026-06-26 08:15 UTC

README

Read-only, DB-backed decision-support guide engine for Laravel — graph-first, with a resumable evaluator, structured & expression conditions, publish-time validation, and Mermaid rendering.

A decision-support guide is a directed graph that asks questions, resolves domain facts, branches on conditions, and ends in an outcome (a verdict plus reasoning and warnings). This package is the engine that evaluates such guides. It is deliberately read-only: it advises, it does not act — no status mutations, no jobs, no writes to your data. Side effects belong in your application, wired through events.

It is built graph-first with a resumable interpreter, so a run can pause to ask the user something and resume later from a serialized state — perfect for a Livewire wizard, an API, or a fully headless/offline evaluation.

  • Framework-only. Depends on illuminate/contracts, illuminate/support, and symfony/expression-language. No UI is assumed.
  • Two storage shapes. Drafts are normalized rows (editable, auditable); published versions are an immutable JSON snapshot the runtime reads.
  • Seams, not coupling. Audit, authorization, translations, and help are host concerns — the engine exposes events and contracts instead of depending on any of them.

Looking for the in-app tree editor and runner UI? That ships separately as byjesper/laravel-decision-support-filament (built on this engine).

Requirements

  • PHP 8.4+
  • Laravel 13 (illuminate/* ^13.0)

Installation

composer require byjesper/laravel-decision-support

The service provider is auto-discovered. Publish the config and/or migrations if you need them:

php artisan vendor:publish --tag=decision-support-config
php artisan vendor:publish --tag=decision-support-migrations
php artisan migrate

The package also loads its migrations automatically, so for app-internal use you can just run php artisan migrate without publishing them.

Concepts at a glance

Concept What it is
Node type A kind of node the engine can evaluate. Built-ins: question, fact, decision, outcome. Custom types are registered by hosts.
Fact provider The developer-owned boundary. Declares the vocabulary of facts a guide may branch on, and resolves them at run time. One per guide.
Condition An edge guard: structured (fact + operator + value) by default, or an expression (symfony/expression-language) as an advanced escape hatch.
Guide definition The immutable, runtime-facing snapshot of a guide (nodes + edges + entry). The runner only ever reads this.
Run state A serializable value object capturing where a run is: current node, status, answers/facts, reached path, pending interaction, or final outcome.
Profile A publish-time shape constraint: phased (questions → facts → decisions → outcomes) or freeform.

Quick start (headless)

Everything below works without a database or UI — ideal for tests and code-authoring consumers.

1. Implement a fact provider

use ByJesper\DecisionSupport\Contracts\FactProvider;
use ByJesper\DecisionSupport\Enums\FactType;
use ByJesper\DecisionSupport\Facts\{FactDefinition, FactValue, FactVocabulary, PendingInteraction};
use ByJesper\DecisionSupport\Runtime\GuideContext;

final class EmploymentFactProvider implements FactProvider
{
    public function vocabulary(): FactVocabulary
    {
        return new FactVocabulary([
            new FactDefinition('tenure_years', FactType::Number),
        ]);
    }

    public function resolve(string $fact, GuideContext $context): FactValue|PendingInteraction
    {
        return new FactValue($this->lookupTenure($context));
        // ...or `new PendingInteraction($interaction)` to suspend for host input.
    }
}

Register it (one provider per guide key) in a service provider's boot():

use ByJesper\DecisionSupport\DecisionSupportManager;

app(DecisionSupportManager::class)
    ->registerProvider('employment-eligibility', EmploymentFactProvider::class);

2. Author a guide

GuideBuilder assembles a definition fluently. The entry node defaults to the first node added.

use ByJesper\DecisionSupport\Conditions\Condition;
use ByJesper\DecisionSupport\Enums\Operator;
use ByJesper\DecisionSupport\Testing\GuideBuilder;

$definition = GuideBuilder::make('employment-eligibility')
    ->profile('phased')
    ->question('q_employed', 'Are you employed?', 'employed', 'boolean')
    ->fact('f_tenure', 'tenure_years')
    ->decision('d_tenure')
    ->outcome('senior', 'Eligible (senior)', 'You qualify under the senior track.')
    ->outcome('junior', 'Eligible (junior)')
    ->outcome('no', 'Not eligible')
    ->edge('q_employed', 'f_tenure', 'true')
    ->edge('q_employed', 'no', 'false')
    ->edge('f_tenure', 'd_tenure')
    ->edge('d_tenure', 'senior', 'out', Condition::structured('tenure_years', Operator::GreaterThanOrEqual, 5))
    ->edge('d_tenure', 'junior', 'out', Condition::always())
    ->build();

3. Run it

use ByJesper\DecisionSupport\Runtime\GuideRunner;

$runner = app(GuideRunner::class);

$state = $runner->start($definition);            // suspends at q_employed
$state->isSuspended();                           // true
$state->pendingInteraction?->prompt;             // 'Are you employed?'

$state = $runner->advance($definition, $state, true);   // answer the question

$state->isCompleted();                           // true
$state->outcome?->verdict;                        // 'Eligible (senior)'
$state->outcome?->warnings;                       // string[]
$state->path;                                     // ['q_employed', 'f_tenure', 'd_tenure', 'senior']

start() and advance() drive the run forward through automatic nodes (fact, decision, outcome) and only hand control back when they need input (a suspension) or finish (an outcome).

Persisting a run across requests

RunState is a plain serializable value object — store it anywhere:

session(['run' => $state->toArray()]);
// ...next request...
$state = RunState::fromArray(session('run'));
$state = $runner->advance($definition, $state, $userInput);

Required (mandatory) questions

A free-input question (text, date, number) can be marked required so a run cannot advance past it on a blank answer:

GuideBuilder::make('intake')
    ->question('q_start', 'Intended start date', 'start_date', 'date', [], [], required: true);

The flag rides on the suspension, so a host UI can react (e.g. show a validation message):

$state->pendingInteraction?->required;            // true

When a required question is answered with a null/whitespace value the interpreter re-suspends on the same node instead of routing an empty value onward. It is ignored for boolean/select, which are always answered by the choice itself.

Multi-language content

Guide content — an outcome's verdict/text/warnings, a question's prompt, and select-option labels — can be authored in several languages. Keep the plain string field as the source/default language and add an optional sibling *_i18n map keyed by locale:

GuideBuilder::make('eligibility')
    ->question('q', 'Are you employed?', 'employed', 'boolean', [], [
        'prompt_i18n' => ['da' => 'Er du ansat?'],
    ])
    ->outcome('yes', 'Eligible', 'You qualify.', [], [
        'verdict_i18n' => ['da' => 'Berettiget'],
        'text_i18n'    => ['da' => 'Du kvalificerer.'],
    ]);
// select options take a per-option 'label_i18n' => ['da' => '…']

The engine is framework-agnostic, so you tell it the locale rather than it reading app()->getLocale(). Pass an active locale (and an optional fallback) to start(); it is carried on the run and survives serialization:

$state = $runner->start($definition, [], 'da');          // da → base
$state = $runner->start($definition, [], 'de', 'da');    // de → da → base

Resolution is *_i18n[$locale] ?? *_i18n[$fallbackLocale] ?? <base string>. With no locale (the default) the base strings are used — fully backward compatible.

Conditions

Edges are guarded by conditions. The default is structured; expressions are opt-in and sandboxed to the fact vocabulary.

use ByJesper\DecisionSupport\Conditions\Condition;
use ByJesper\DecisionSupport\Enums\Operator;

Condition::structured('tenure_years', Operator::GreaterThanOrEqual, 5);
Condition::expression('tenure_years >= 5 and contract_type == "permanent"');
Condition::always();              // default / else branch
Condition::unknown('tenure_years'); // matches only when the fact is unresolved

Operators: =, !=, >, >=, <, <=, in, not_in, is_true, is_false.

A decision node emits a single out port and lets its outgoing edges decide the target: the first matching condition wins, with an always edge as the default. Provide a default or unknown branch so unresolved facts always route somewhere.

Working with the database

Model a guide as Guide → GuideVersion → GuideNode/GuideEdge, then validate and publish a draft. Publishing freezes the draft rows into the immutable definition snapshot and points the guide's active_version_id at it.

use ByJesper\DecisionSupport\Publishing\GuidePublisher;

$result = app(GuidePublisher::class)->publish($version);

if ($result->fails()) {
    // Nothing was published. Each error has a code, message, and optional nodeKey.
    foreach ($result->errors as $error) {
        logger()->warning($error->code, ['message' => $error->message, 'node' => $error->nodeKey]);
    }
}

$definition = $version->fresh()->toDefinition();   // the published snapshot

Extra attributes (consumer metadata)

Both Guide and GuideVersion carry a nullable extra_attributes JSON column (cast to array) for arbitrary consumer-defined metadata — the headline use case is the permissions required to see or run a guide:

$guide->extra_attributes = ['permissions' => ['view-guide']];
$guide->save();

The guide-level copy is the source of truth. The version-level copy is an editable working copy that travels with a version; publishing seeds the guide's copy from the version that becomes active (alongside active_version_id). An admin may also edit the guide copy directly between publishes, and that edit takes effect immediately.

The engine stores and copies these attributes but enforces nothing — gating is the host's job. Read them from your Guide policy:

public function view(User $user, Guide $guide): bool
{
    $required = $guide->extra_attributes['permissions'] ?? [];

    return collect($required)->every(fn (string $p): bool => $user->can($p));
}

Publish validation

PublishValidator rejects a draft loudly rather than letting a broken guide reach the runtime. It checks:

  • Graph integrity — a resolvable entry, no dangling edges, no orphan (unreachable) nodes, every declared port has an outgoing edge.
  • Termination — the graph is acyclic, and every leaf is an outcome (so every path reaches a verdict).
  • Fact references — every structured condition's fact is in the vocabulary; expression conditions are linted against it.
  • Per-node config — e.g. a question needs a prompt; an outcome needs a verdict.
  • Profile rules — e.g. phased forbids edges that move backwards across phases.

Safety rails

The runtime never throws on bad guide data:

  • A missing fact routes to a defined unknown/default branch.
  • A cycle (a node re-entered on the same path) terminates with an unknown outcome.
  • A step budget (config('decision-support.max_steps'), default 200) caps runaway runs.

An unknown outcome ($state->outcome->unknown === true) signals a rail fired, with the reason in its text/warnings.

Rendering a diagram

MermaidRenderer is a pure function from a definition (plus an optional run state) to Mermaid flowchart source — the same renderer powers an editor preview and a runner view. Pass a RunState to highlight the reached path.

use ByJesper\DecisionSupport\Mermaid\MermaidRenderer;

$mermaid = app(MermaidRenderer::class)->render($definition, $state);

Node text is localized through the same locale chain as the runner (locale → fallback → base). A run state carries its own locale, so a highlighted diagram localizes automatically; for a diagram with no run state (e.g. a pre-start preview) pass the locale explicitly:

$mermaid = app(MermaidRenderer::class)->render($definition, null, 'da', 'en');

Each node resolves its display text from an explicit label (with an optional label_i18n map) first, then the type's content field (prompt/prompt_i18n for a question, verdict/verdict_i18n for an outcome, the fact name for a fact/decision), then the node key. GuideBuilder::fact()/decision() take an optional $label and $labelI18n so authored graphs show friendly labels instead of raw keys.

Edge labels follow the same rule: by default an edge shows its derived condition/port text (tenure >= 5, else, a boolean port…), but giving the edge a label (and optional labelI18n) overrides that with humanised, localized text. GuideBuilder::edge() accepts $label/$labelI18n.

Extending the engine

Register everything on the DecisionSupportManager (typically in boot()):

$manager = app(\ByJesper\DecisionSupport\DecisionSupportManager::class);

$manager->registerProvider('some-guide', SomeFactProvider::class); // fact provider per guide
$manager->registerNodeType(new MyCustomNode());                     // implements NodeType
$manager->registerProfile(new MyProfile());                         // implements GuideProfile

A custom node type implements ByJesper\DecisionSupport\Contracts\NodeType and returns NodeResult::advance(), ::suspend(), or ::terminate() from evaluate() — that is all the engine needs to fold it into the same resumable loop as the built-ins. Its configSchema() drives the Filament editor form; each field may include an optional help string the editor renders as hint text (the engine itself does not interpret the schema).

Events (host seams)

The engine emits events instead of depending on your audit/authorization stack. Listen to these to wire side effects:

Event When
GuideRunStarted A run begins (carries the initial RunState).
GuidePublished A version is published.
GuideDrafted A draft version is created.
NodeChanged A node is edited.

Testing your guides

The package ships first-class test helpers (no DB, no editor required):

use ByJesper\DecisionSupport\Testing\{FakeFactProvider, GuideBuilder, InteractsWithGuides};

uses(InteractsWithGuides::class);

it('reaches the senior outcome', function () {
    $guide  = GuideBuilder::make('employment-eligibility')/* ... */->build();
    $runner = $this->decisionRunner('employment-eligibility', FakeFactProvider::make()->with('tenure_years', 6));

    $state = $runner->advance($guide, $runner->start($guide), true);

    $this->assertReachesOutcome($state, 'Eligible (senior)');
});

Helpers: decisionRunner(), assertReachesOutcome(), assertReachesUnknown(), assertSuspendsForQuestion(), plus FakeFactProvider (->with(), ->pending(), ->declare()) and GuideBuilder. Outside PHPUnit, GuideTester exposes the same helpers as a standalone object.

Laravel Boost

This package ships a Laravel Boost skilldecision-support-development (resources/boost/skills/decision-support-development/SKILL.md). When a consuming app runs php artisan boost:install (or boost:update --discover), Boost offers to install it. It is loaded on-demand — only when the agent is actually authoring guides, fact providers, node types, or conditions — so it adds no upfront context cost to apps that aren't touching this engine.

Testing

composer test

This runs the full gate: guideline check, lint (Pint + Rector), static analysis (Larastan level 8), 100% type coverage, and the unit, parallel, and integration suites. Database-bound tests are tagged ->group('integration') and run against an in-memory SQLite connection.

License

The MIT License (MIT). See LICENSE.md.