waaseyaa / workflows
Content moderation and editorial workflow states for Waaseyaa
Requires
- php: >=8.5
- waaseyaa/access: ^0.1.0-alpha.256
- waaseyaa/audit: ^0.1.0-alpha.256
- waaseyaa/config: ^0.1.0-alpha.256
- waaseyaa/entity: ^0.1.0-alpha.256
- waaseyaa/foundation: ^0.1.0-alpha.256
- waaseyaa/relationship: ^0.1.0-alpha.256
Requires (Dev)
- phpunit/phpunit: ^10.5
- waaseyaa/database-legacy: ^0.1.0-alpha.256
- waaseyaa/entity-storage: ^0.1.0-alpha.256
This package is auto-updated.
Last update: 2026-07-06 21:32:33 UTC
README
Layer 3 — Services
Content moderation and editorial workflow states for Waaseyaa applications.
CW-v1 engine (WP-1, live)
The content-workflow engine (docs/specs/content-workflow.md) is now the canonical surface: named
editorial states, permission-gated transitions, enforced in the write path, with transition audit
history. Workflows are config, not code — the default editorial workflow ships as declarative
seed data (DefaultWorkflows::EDITORIAL), not a hardcoded preset class.
Workflow/WorkflowState/WorkflowTransition— the config-entity primitives. States now carrypublished/defaultRevisionflags; transitions carry an explicit or derivedpermissionstring (use {workflow_id} transition {transition_id}); the workflow itself carries aninitial_state.Validation\WorkflowValidator— structural checks (unknown states infrom/to, unknowninitial_state, zero states) used at seed/import time.Binding\WorkflowBindingResolver— resolves the workflow bound to an entity type + bundle via theworkflows.assignmentsconfig ({type}.{bundle}exact key wins over{type}.*wildcard); throws for a non-revisionable bound type or an unknown workflow id.Transition\TransitionService— the one enforcement door:transition()validates (binding exists, transition exists, current state is a legalfrom, account holds the permission) → applies (workflow_state+statusper the target state'spublishedflag) → persists throughEntityTypeManagerInterface::getRepository()->save()(never a direct storage write) → announces (WorkflowEvents::PRE_TRANSITION/POST_TRANSITION) → records a best-effort audit entry (AuditEventKind::WorkflowTransition). Denials throwTransition\TransitionDeniedExceptionwith a machine-readablereason(unbound,unknown_transition,illegal_edge,permission) — never a silent no-op.getAvailableTransitions()is the sanctioned read-side for UIs.Listener\WorkflowStateGuard— aEntityEvents::PRE_SAVEsubscriber that makes the raw entity save path equivalent toTransitionService: a create is forced intoinitial_stateunless the acting account can reach a different state via a single legal + permitted transition (closing the born-published hole); an update'sworkflow_statechange is validated exactly like a transition (permission required whenever an actingAccountContextInterfacecontext exists; a null context — CLI/queue/programmatic — checks edge-legality only).WorkflowServiceProvider::boot()wires the guard onto the real dispatcher (Symfony-contracts FQCN) and seeds the defaulteditorialworkflow if absent (log-and-skip on validation failure, never boot-crash).
Revision promotion (default_revision forward-draft mechanics) is WP-2; group/department
transition constraints are WP-3; API transition endpoints + admin SPA are WP-4.
Legacy machinery (superseded, removal tracked as WP-5 / #1920)
The classes below predate the CW-v1 engine and are not wired to any enforcement path —
EditorialWorkflowService::transitionNode() mutates fields in memory only; the caller must save
separately, with no guard proving the save is legitimate. They are kept only until WP-5 deletes them
(never before the engine landed, which it now has):
ContentModerator/ContentModerationState— the original state-machine driver, superseded byTransitionService.EditorialWorkflowPreset— the preset-in-code editorial definition, superseded byDefaultWorkflows(data, not code).EditorialWorkflowService/EditorialTransitionAccessResolver/AuthoringRoleMatrix— the ungated mutate-and-hope-you-saved path and its permission/role lookups, superseded byTransitionService+WorkflowStateGuard.DomainValidationListener— never subscribed to any dispatcher; dead code kept alive in the dead-code gate only by its own unit test.
Live, unaffected by CW-v1: WorkflowVisibility / WorkflowVisibilityFilter /
EditorialVisibilityResolver remain the read-side gates (fail-closed per R16 #1915). For
CW-v1-bound entity types they will derive published-semantics from the bound workflow's state
flags instead of assuming status === 1 — tracked for WP-2.
Install
Ships as part of waaseyaa/framework — consumers who require the metapackage (or core / cms /
full) get it transitively. To depend on it directly:
composer require waaseyaa/workflows
WorkflowServiceProvider is auto-discovered via extra.waaseyaa.providers; it registers the
workflow config entity type, binds the engine services (WorkflowBindingResolver,
TransitionService, WorkflowStateGuard), the default AuthoringRoleMatrix, wires the save-path
guard, and seeds the default editorial workflow. Requires PHP >= 8.5.
Key API
// Workflow.php — config entity (states + transitions) public function addState(WorkflowState $state): static public function getState(string $id): ?WorkflowState public function addTransition(WorkflowTransition $transition): static public function getValidTransitions(string $fromStateId): array // keyed by transition ID public function isTransitionAllowed(string $fromStateId, string $toStateId): bool public function getInitialState(): string public function permissionFor(WorkflowTransition $transition): string // WorkflowState.php — readonly value object public function __construct(string $id, string $label, int $weight = 0, array $metadata = [], bool $published = false, bool $defaultRevision = false) // WorkflowTransition.php — readonly value object (from: string[], to: string) public function __construct(string $id, string $label, array $from, string $to, int $weight = 0, string $permission = '') // Validation\WorkflowValidator.php public function validate(Workflow $workflow): array // list<string> violations; [] = valid // Binding\WorkflowBindingResolver.php public function resolve(string $entityTypeId, string $bundle): ?Workflow // null = unbound // Transition\TransitionService.php — the one enforcement door public function transition(EntityInterface $entity, string $transitionId, AccountInterface $account): TransitionResult public function getAvailableTransitions(EntityInterface $entity, AccountInterface $account): array // WorkflowTransition[] // Transition\TransitionDeniedException.php — reason is one of unbound/unknown_transition/illegal_edge/permission public readonly string $reason; // Listener\WorkflowStateGuard.php — PRE_SAVE subscriber public function onPreSave(EntityEvent $event): void
Usage
use Waaseyaa\Workflows\Transition\TransitionDeniedException; use Waaseyaa\Workflows\Transition\TransitionService; // $service is container-resolved (Waaseyaa\Workflows\Transition\TransitionService::class). try { $result = $service->transition($node, 'publish', $account); // $result->toState === 'published' } catch (TransitionDeniedException $e) { // $e->reason: 'unbound' | 'unknown_transition' | 'illegal_edge' | 'permission' } // UIs render buttons from the read side only: foreach ($service->getAvailableTransitions($node, $account) as $transition) { // $transition->id, $transition->label }
Legacy usage (superseded — see above; do not build new code against this)
use Waaseyaa\Workflows\ContentModerationState; use Waaseyaa\Workflows\ContentModerator; use Waaseyaa\Workflows\EditorialWorkflowPreset; $moderator = new ContentModerator(); $moderator->addWorkflow(EditorialWorkflowPreset::create()); // id: 'editorial' $state = new ContentModerationState( entityTypeId: 'node', entityId: 1, workflowId: 'editorial', stateId: 'draft', ); $state = $moderator->transition($state, 'review'); // draft -> review $state->stateId; // 'review' // Disallowed transitions throw \InvalidArgumentException. $moderator->getAvailableTransitions($state); // WorkflowTransition[] valid from 'review'