The ORM-backed, data-driven state machine of the Milpa PHP framework: states, transitions and approval gates — with evidence attachments — configured in the database instead of hardcoded, plus
StateMachineVerifier, a bridge ontomilpa/core'sVerifierInterface.
milpa/workflow is the family's first tier-2 package: Doctrine ORM is an honest
runtime dependency (used only through findBy/findOneBy/QueryBuilder on the
package's own entities), not a zero-dep primitive like milpa/core. Everything else
about the family still holds — no product coupling, no concrete container, opaque
identity.
composer require milpa/workflowuse Milpa\Workflow\StateMachine\DataDrivenStateMachine;
use Milpa\Workflow\StateMachine\TransitionContext;
use Milpa\Workflow\Gates\DefaultGateEvaluator;
use Milpa\Workflow\Services\GatePassageService;
// $em is a Doctrine\ORM\EntityManagerInterface wired to a schema that has run
// doctrine/orm's schema tool over Milpa\Workflow\Entities\*; $gate is a
// GateDefinition your domain configured (e.g. persisted via a migration/seeder).
$stateMachine = new DataDrivenStateMachine($em, new DefaultGateEvaluator());
$gateService = new GatePassageService($em);
// Ask whether 'lead' -> 'qualified' is currently possible for domain 'opportunity'.
$context = new TransitionContext(actorId: 7, actorRole: 'sales', entityId: 99, domain: 'opportunity');
$result = $stateMachine->canTransition('opportunity', 'lead', 'qualified', $context);
if (!$result->isPassed()) {
// $result->missingFields / $result->missingEvidence explain exactly what's missing.
}
// Request (and later approve) a gate passage against a GateDefinition your domain
// configured (e.g. `$em->getRepository(GateDefinition::class)->findOneBy([...])`).
// Principals are opaque strings the engine never resolves — the consuming product
// owns identity.
$passage = $gateService->requestPassage($gate, 'opportunity', 99, requesterId: 'member:7');
$gateService->approvePassage($passage, approverId: 'member:12', notes: 'fit score confirmed');Contracts\StateMachineInterface(implemented byStateMachine\DataDrivenStateMachine) — readsEntities\StateDefinition/TransitionDefinition/GateDefinitionfrom the database to answercanTransition(),getAvailableTransitions(),transition(), and lookups by domain/code. No transition graph is hardcoded in PHP.Contracts\GateServiceInterface— request/approve/reject/waive anEntities\GatePassage, with the anti-self-approval constraint enforced at the service layer (Exceptions\SelfApprovalException). Two implementations ship:Services\GatePassageService(Doctrine-backed, append-only persisted rows) andServices\InMemoryGateService(zero-DB, for consumers with noEntityManager) — see "Gate services: Doctrine and in-memory" below.Verification\StateMachineVerifier— runs the gate machinery throughmilpa/core'sVerifierInterface: a genericVerificationRequest/VerificationContextin, aVerificationResultout, bridged losslessly throughStateMachine\GateResult. This is the monorepo's only automatedVerifierInterfaceimplementation — the deterministic counterpart to a human/agent-supplied verification.- Opaque identity everywhere a principal is stored:
Entities\Evidence::$uploadedByandEntities\GatePassage::$requestedBy/$approvedByare plain strings such as"member:42". The engine never resolves them to an entity — the consuming product owns identity resolution (seeADR-001/ design note D9). - No product coupling. The polymorphic
entity_type/entity_idpair onGatePassagelets any domain (opportunity, project, invoice, ...) register its own states/transitions/gates without this package knowing about it.
Contracts\GateServiceInterface has two implementations, picked by whether the consumer
has a Doctrine EntityManagerInterface at all:
Services\GatePassageService— the original, Doctrine-backed implementation.persist()/flush()s eachEntities\GatePassageas an append-only row and can answergetApprovedPassagesForEntity()with a realQueryBuilderquery. Requires anEntityManagerInterfacein its constructor.Services\InMemoryGateService— a non-Doctrine implementation for zero-DB / event-sourced consumers (e.g.milpa/orchestratorreplaying its own append-only event log) that have noEntityManagerInterfaceto constructGatePassageServicewith. Requires zero constructor arguments (an optionalAuditLoggerInterfaceis the only parameter). It returns the sameEntities\GatePassageentityGatePassageServicedoes —GatePassage's constructor/setters are plain PHP, sonew GatePassage()works without Doctrine as long as its Doctrine-generatedgetId()is never read; this class usesGatePassage::getUuid()wherever a stable identifier is needed instead. State (recorded approvers, the approved-passages-per-entity index) lives only for the lifetime of the service instance — keep one alive per request/process if you needApprovalPolicy::DUAL's two-distinct-approvers count to span more than one call. It is also the first implementation to actually honor a gate'sApprovalPolicy:DUALrequires two distinct approver principals across twoapprovePassage()calls before the passage leavesREQUESTED;SINGLE,QUORUM, andAUTOall resolve on the first approval (see the class DocBlock for whyQUORUM/AUTOfall back that way).
Both implementations enforce the same D9 self-approval guard on approvePassage()
(Exceptions\SelfApprovalException) and the same waivability guard on waiveGate()
(Exceptions\NonWaivableGateException) — pick the implementation that matches your
persistence story; the interface (and every other collaborator, like
Verification\StateMachineVerifier) doesn't care which one it's talking to.
Public contracts live under Contracts/ (not Interfaces/, which is milpa/core's
established, already-published convention) — this package follows the family
convention adopted from ola 2 onward: new packages use Contracts/ for
interfaces/value-objects that form the package's public seam, while
concern-scoped strategy interfaces stay colocated with their concern
(StateMachine\GateEvaluatorInterface lives in StateMachine/, next to the
GateResult/TransitionContext types it operates on).
The test suite is standalone: it exercises the state machine's transition/gate
logic and the StateMachineVerifier bridge against stub implementations of
Contracts\StateMachineInterface/StateMachine\GateEvaluatorInterface and
hand-built entity graphs — no live database, no Doctrine bootstrap. The two
Doctrine-backed services (StateMachine\DataDrivenStateMachine's repository
lookups and Services\GatePassageService's persistence) talk to
EntityManagerInterface through findBy/findOneBy/QueryBuilder only; nothing
in this package requires a schema migration or a running MySQL to validate its
logic.
- PHP ≥ 8.3
doctrine/orm^3.0 (the package's one runtime dependency beyondmilpa/core)milpa/core—VerifierInterface, theVerificationRequest/VerificationContext/VerificationResultvalue objects, andSupport\UuidGenerator
Full API reference: getmilpa.github.io/workflow — generated straight from the source DocBlocks and dressed with the Milpa design system.
Contributions are welcome — see CONTRIBUTING.md. Please report security issues via SECURITY.md, and note that this project follows a Code of Conduct.
Apache-2.0 © Rodrigo Vicente - TeamX Agency.
Milpa is designed, built, and maintained by Rodrigo Vicente - TeamX Agency.