byjesper / laravel-decision-support
Read-only, DB-backed decision-support guide engine for Laravel (graph-first, resumable evaluator, Mermaid).
Package info
github.com/byjesper/laravel-decision-support
pkg:composer/byjesper/laravel-decision-support
Requires
- php: ^8.4
- illuminate/contracts: ^13.0
- illuminate/support: ^13.0
- symfony/expression-language: ^7.0
Requires (Dev)
- byjesper/laravel-coding-guidelines: ^0.1
- driftingly/rector-laravel: ^2.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- pestphp/pest-plugin-type-coverage: ^4.0
- phpstan/phpstan: ^2.0
- rector/rector: ^2.0
This package is auto-updated.
Last update: 2026-06-26 08:15:53 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, andsymfony/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 migratewithout 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.
phasedforbids 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
unknownoutcome. - A step budget (
config('decision-support.max_steps'), default200) 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 skill —
decision-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.