Instructions for AI agents working on this codebase. Read CLAUDE.md first for project context.
- Read before you write. Never modify a file you haven't read. Understand the existing patterns.
- Zero production dependencies. This is a framework-agnostic library. Never add runtime
requireentries to composer.json. Dev dependencies are fine. - PHP 8.3+ only. Use readonly properties, backed enums, match expressions, named arguments, constructor promotion. No legacy patterns.
- Run the full quality pipeline after any change:
composer ci(pint:test + analyze + test). - PHPStan level 6. All new code must pass. Don't suppress errors unless truly unavoidable—and document why.
- Pest PHP for tests. Use
test()syntax,expect()assertions,describe()blocks. No raw PHPUnit$this->assert*(). - Laravel Pint formatting. Run
composer pintbefore committing. The CI will reject improperly formatted code.
When reviewing PRs or code changes for this project:
- Contract compliance: Does new code implement
WorkflowAction,StorageAdapter,EventDispatcher, orLoggercorrectly? - Immutability:
WorkflowContext,ActionResult,WorkflowDefinitionare value objects. Don't add setters or mutation methods. - State transitions: Any code that calls
setState()must respect the transition rules inWorkflowState::canTransitionTo(). Invalid transitions are bugs. - Action instantiation: Actions are created with
new $actionClass($config, $logger). If you change theWorkflowActioninterface orBaseActionconstructor, you must updateExecutor::executeAction(). - No framework coupling: This package must not
useany Laravel, Symfony, or other framework classes insrc/. The only exception isdata_get()/data_set()helpers (which need to be replaced—see Known Issues). - Exception hierarchy: All exceptions must extend
WorkflowException. Use the static factory methods (e.g.,InvalidWorkflowDefinitionException::invalidName()), not rawnewconstructors. - Event consistency: Every state-changing operation should dispatch the appropriate event. Check that
WorkflowStarted,WorkflowCompleted,WorkflowFailed,WorkflowCancelled,StepCompleted,StepFailedare dispatched at the right moments.
- Adding
declare(strict_types=1)piecemeal—if we add it, it goes in every file at once. - Magic string config keys without constants or typed config objects.
- Suppressing PHPStan errors without a clear comment explaining why.
- Tests that use
assertTrue(true)or other vacuous assertions. - New public API methods without PHPDoc
@param,@return, and@throwstags.
tests/
├── Unit/ # Isolated class tests (no I/O, no storage)
├── Integration/ # Multi-step workflow execution through the engine
├── RealWorld/ # Complex production-like scenarios
├── Actions/ECommerce/ # Custom action fixtures used by RealWorld tests
├── Support/ # Test helpers (InMemoryStorage)
├── TestCase.php # Base class: provides $this->engine + $this->storage
├── Pest.php # Pest config
├── ArchTest.php # Architecture constraints
└── ExampleTest.php # Sanity check
// Good: descriptive, focused, uses Pest syntax
test('workflow transitions to failed state when action throws', function () {
$definition = [
'name' => 'failing-workflow',
'steps' => [
['id' => 'bad_step', 'action' => NonExistentAction::class],
],
];
expect(fn () => $this->engine->start('test-fail', $definition))
->toThrow(ActionNotFoundException::class);
});
// Good: grouped with describe
describe('WorkflowBuilder', function () {
test('validates step IDs', function () { ... });
test('rejects empty workflow names', function () { ... });
});These areas currently lack test coverage. Prioritize them when writing new tests:
- Event dispatch verification — No tests confirm events are actually dispatched. Create a
SpyEventDispatcherthat records dispatched events and assert against it. - Retry logic —
StepsupportsretryAttemptsbut theExecutornever actually retries. When retry is implemented, add tests for 0, 1, and max retries. - HTTP/Email actions —
HttpActionandEmailActionhave no unit tests. Mock the underlying operations and test config validation, error handling, and result mapping. - Storage adapter edge cases — Only
InMemoryStorageis tested. Add tests for: loading a non-existent instance, concurrent saves, findInstances with various filter combinations. - Condition evaluation — The regex-based condition parser in
Step::evaluateCondition()silently returnstruefor unparseable conditions. Test edge cases: nested properties, numeric comparisons, boolean values, empty strings. - Compensation actions —
StepsupportscompensationActionbut nothing executes it. When implemented, test rollback sequences. - Pause/resume cycles — Test multiple pause → resume → pause sequences and verify state consistency.
- Check if the feature touches any contract interface. Interface changes require updates to all implementations (including
InMemoryStorage,NullLogger,NullEventDispatcher). - Check if the change affects the builder API. Builder changes should maintain backward compatibility—add new methods, don't change existing signatures.
- Check if new exceptions are needed. Use the existing hierarchy and static factory pattern.
Creating a new Action:
class MyAction extends BaseAction
{
protected function doExecute(WorkflowContext $context): ActionResult
{
$value = $this->getConfig('key');
// ... business logic ...
return ActionResult::success(['output' => $result]);
}
public function getName(): string
{
return 'My Action';
}
public function getDescription(): string
{
return 'Does something specific';
}
}Creating a new Exception:
class MyException extends WorkflowException
{
public static function specificCase(string $id): self
{
return new self(
message: "Technical: thing failed for {$id}",
userMessage: "The operation could not be completed.",
suggestions: ['Check the configuration', 'Verify the ID exists'],
context: ['id' => $id]
);
}
}Creating a new Event:
class MyEvent
{
public function __construct(
public readonly string $workflowId,
public readonly string $detail,
public readonly \DateTimeInterface $occurredAt = new \DateTime(),
) {}
}These are real issues found through code review, ordered by impact. This is not a wishlist—these are bugs, missing implementations, and design problems that need fixing before v1.0.
1. Timeout is configured but never enforced
Step accepts a timeout parameter. WorkflowBuilder validates it. But Executor::executeStep() never checks it. A misbehaving action can hang the entire process indefinitely. The TODO comment at Executor.php:230 confirms this is known.
Fix: Implement timeout enforcement in Executor::executeStep() using pcntl_alarm() or a wrapper that throws StepExecutionException on timeout. Add a TimeoutException to the exception hierarchy.
2. Retry logic is declared but never executed
Step::getRetryAttempts() returns a value, but Executor catches exceptions and immediately fails. No retry loop exists anywhere in the execution path.
Fix: Add a retry loop in Executor::executeStep() with exponential backoff. Dispatch StepRetryEvent on each retry attempt. Track attempt count on the WorkflowInstance.
3. Compensation actions are defined but never called
Step supports compensationAction and hasCompensation(). Nothing in the codebase ever calls a compensation action. The Saga pattern is advertised but not implemented.
Fix: When a step fails, walk backward through completed steps and execute their compensation actions. Add a CompensationExecutedEvent. This is a significant feature — design it before implementing.
4. data_get() / data_set() Laravel helper dependency
Step::evaluateCondition() and WorkflowDefinition::evaluateCondition() call data_get(), a Laravel helper. This function doesn't exist in non-Laravel environments. The package advertises itself as framework-agnostic but will throw a fatal error without Laravel's helpers.
Fix: Implement a simple Support\Arr::get() utility that handles dot-notation access, or inline the logic. Remove the PHPStan suppression for data_get/data_set.
5. Duplicate condition evaluation logic
Step::evaluateCondition() (line 221) and WorkflowDefinition::evaluateCondition() contain identical regex-based condition parsing. This violates DRY and means bug fixes must be applied in two places.
Fix: Extract to a Support\ConditionEvaluator class. Both Step and WorkflowDefinition should delegate to it.
6. Silent condition parsing failures
Step::evaluateCondition() returns true when it can't parse a condition (line 244). This means a typo in a condition expression (user.plan = "premium" instead of ===) will silently pass, executing steps that should be skipped.
Fix: Throw InvalidWorkflowDefinitionException for unparseable conditions, or at minimum log a warning. Never silently succeed.
7. Inconsistent event class naming
Some events end with Event (StepCompletedEvent, WorkflowCompletedEvent, WorkflowFailedEvent) and some don't (WorkflowStarted, WorkflowCancelled). Pick one convention and stick with it.
Fix: Rename all to *Event suffix for consistency: WorkflowStartedEvent, WorkflowCancelledEvent.
8. Duplicate API methods on WorkflowEngine
getInstance() and getWorkflow() do the same thing (lines 203 and 265). getInstances() and listWorkflows() do the same thing (lines 238 and 294). This confuses consumers and doubles the API surface.
Fix: Deprecate getWorkflow() and listWorkflows(). Keep getInstance() and getInstances() as the canonical API. Remove the deprecated methods before v1.0.
9. Action constructor not enforced by contract
Executor::executeAction() (line 300) does new $actionClass($config, $logger). The WorkflowAction interface doesn't define a constructor, so custom actions with different constructors will fail at runtime with a cryptic error.
Fix: Either document the constructor contract clearly, add a static factory method to the interface (WorkflowAction::make($config, $logger)), or use an ActionFactory that can be overridden for DI containers.
10. WorkflowInstance does too much
WorkflowInstance handles state tracking, progress calculation, step management, data merging, serialization, and error recording. It's a god object.
Fix: Extract WorkflowProgress (progress calculation, step completion tracking) and WorkflowSerializer (toArray/fromArray) into separate concerns. Keep WorkflowInstance focused on identity and state.
11. No middleware/pipeline for cross-cutting concerns
Retry, timeout, logging, and metrics all need to wrap step execution. Currently there's no clean way to add these behaviors without modifying Executor directly.
Fix: Implement a StepMiddleware interface and a pipeline in Executor that chains middleware around action execution. Ship RetryMiddleware, TimeoutMiddleware, and LoggingMiddleware as built-in implementations.
12. Condition evaluator is too limited The regex-based condition parser only supports simple binary comparisons. No AND/OR, no grouping, no function calls. This limits real-world usefulness significantly.
Fix: Consider a simple expression parser or adopt a lightweight expression language. At minimum, support AND (&&) and OR (||) operators.
13. Missing declare(strict_types=1)
No source file declares strict types. This allows implicit type coercion which can hide bugs.
14. PHPStan could be stricter
Level 6 is good but not maximum. Several errors are suppressed in phpstan.neon.dist. Work toward level 8 by fixing the underlying type issues rather than suppressing them.
15. Excessive inline documentation
Some classes (e.g., WorkflowContext) have 600+ lines with extensive code examples in PHPDoc. This makes files hard to navigate. Move examples to a docs/ directory or the README.
16. No integration test for event dispatching
The event system is a core feature but no test verifies that events are dispatched. Add a SpyEventDispatcher to the test support classes and use it in integration tests.
Before approving any PR, verify:
-
composer cipasses (pint:test + phpstan + pest) - New public methods have
@param,@return, and@throwsPHPDoc - No new PHPStan suppressions without justification
- No framework-specific imports in
src/ - State transitions respect
WorkflowState::canTransitionTo() - New features have corresponding tests in the appropriate directory
- Exception messages are helpful (use static factory methods with context)
- Events are dispatched for state-changing operations
- No
dd(),dump(),var_dump(), orray()calls (ArchTest enforces this) - Builder API changes are backward-compatible