waaseyaa / workflows
Content moderation and editorial workflow states for Waaseyaa
Requires
- php: >=8.5
- waaseyaa/access: ^0.1.0-alpha.259
- waaseyaa/audit: ^0.1.0-alpha.259
- waaseyaa/config: ^0.1.0-alpha.259
- waaseyaa/entity: ^0.1.0-alpha.259
- waaseyaa/entity-storage: ^0.1.0-alpha.259
- waaseyaa/foundation: ^0.1.0-alpha.259
- waaseyaa/relationship: ^0.1.0-alpha.259
Requires (Dev)
- phpunit/phpunit: ^10.5
- waaseyaa/database-legacy: ^0.1.0-alpha.259
- waaseyaa/node: ^0.1.0-alpha.259
This package is auto-updated.
Last update: 2026-07-10 21:38:45 UTC
README
Layer 3 — Services
Content moderation and editorial workflow states for Waaseyaa applications.
CW-v1 engine (WP-1 + WP-2, 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 both guards below onto the real dispatcher (Symfony-contracts FQCN) and seeds the defaulteditorialworkflow if absent (log-and-skip on validation failure, never boot-crash).Listener\WorkflowPointerMoveGuard— aBeforeRevisionPointerMoveEvent(L1) subscriber that closes the pointer-move bypass:rollback(),setCurrentRevision(), andsetPublishedRevision()move the base-row pointer WITHOUT adoSave()write, soWorkflowStateGuardalone could not see them. Validates the implied state change like a transition — same-state moves (e.g. promoting a forward draft, or rolling back to an earlier same-state revision) need the permission of any transition targeting that state; different-state moves need the real edge's own permission, no exceptions.- Forward drafts (engine substrate, WP-2):
nodeopts into revisionable storage (revisionDefault: true, per-bundle opt-out viaNodeType::isNewRevision());TransitionServiceimplements the two-pointer status semantics — the base row'sstatusalways reflects the published-pointer revision's state, never the tip's. The engine supports a forward-draft entry edge (editing content back into adefault_revision: falsestate while the published pointer keeps serving the live revision) on any workflow that defines one; a laterpublishpromotes it (pointer moves,statusflips only after the pointer move commits — a guard denial never leavesstatusflipped with the pointer stuck). Raw saves never enact pointer moves — onlyTransitionService(or a direct, sanctioned repository call) moves the pointer. Forward drafts (a published → draft edge on the shippededitorialworkflow) are deferred: the WP-2 review found no read path is pointer-aware, so a forward draft's tip content is served byfind()-based readers while status/pointer reflect the published revision. Forward drafts return on true default-revision semantics (the base row keeps serving the published revision; drafts live only in revision rows).restore_to_published(archived → published) rounds out the shippededitorialworkflow alongsiderestore(archived → draft) so archived-content republishing has real edges — that round trip does not carry the live-content read-side risk above, since the entity is unpublished throughout. Backfilling legacy content'sworkflow_stateonto binding activation is a CLI step,workflows:backfill-state(seedocs/specs/operations-playbooks.mdPlaybook H) — deliberately binding-scoped, not framework-scoped, since the framework cannot know in advance which workflow a site will bind. Full mechanics:docs/specs/content-workflow.md"Forward-draft mechanics".
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 — with a known WP-2 read-side gap: WorkflowVisibility /
WorkflowVisibilityFilter / EditorialVisibilityResolver remain the read-side gates (fail-closed
per R16 #1915). WorkflowVisibility::nodeState() trusts a raw workflow_state value over status
whenever both are present — correct pre-WP-2 (tip and published-facing state were always the same
row), but wrong for a forward draft: the tip's workflow_state (e.g. draft, mid-edit) now
disagrees with the base row's status, which stays correctly pointer-derived. Concretely: editing
published content into a forward draft makes WorkflowVisibility report the node as unpublished
while the published pointer still serves it live — confirmed to affect
Waaseyaa\AI\Vector\EntityEmbeddingListener::onPostSave(), which de-indexes still-live content on
every forward-draft save. Deferred out of WP-2 (a correct fix flips a precedence a pinned unit test
asserts and touches six consumer packages that each new WorkflowVisibility() inline) rather than
rushed; tracked as a WP-2 follow-up. Full write-up: docs/specs/content-workflow.md "Visibility
(read side)".
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'