From 53361525f0307e51bd948acd16ad6fdf4b44f18b Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Thu, 9 Apr 2026 19:38:29 -0500 Subject: [PATCH 1/8] Wake parent after last child (#377) --- src/ChildWorkflow.php | 39 ++++++++++---- tests/Unit/ChildWorkflowTest.php | 89 ++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/src/ChildWorkflow.php b/src/ChildWorkflow.php index 631b3c5a..58d6e6ad 100644 --- a/src/ChildWorkflow.php +++ b/src/ChildWorkflow.php @@ -55,17 +55,19 @@ public function uniqueId() public function handle() { - $workflow = $this->parentWorkflow->toWorkflow(); + if (! $this->parentWorkflow->hasLogByIndex($this->index)) { + $this->parentWorkflow->toWorkflow() + ->next($this->index, $this->now, $this->storedWorkflow->class, $this->return, shouldSignal: false); + } - try { - if ($this->parentWorkflow->hasLogByIndex($this->index)) { + if ($this->shouldWakeParent()) { + $workflow = $this->parentWorkflow->toWorkflow(); + try { $workflow->resume(); - } else { - $workflow->next($this->index, $this->now, $this->storedWorkflow->class, $this->return); - } - } catch (TransitionNotFound) { - if ($workflow->running()) { - $this->release(); + } catch (TransitionNotFound) { + if ($workflow->running()) { + $this->release(); + } } } } @@ -76,4 +78,23 @@ public function middleware() new WithoutOverlappingMiddleware($this->parentWorkflow->id, WithoutOverlappingMiddleware::ACTIVITY, 0, 15), ]; } + + private function shouldWakeParent(): bool + { + $children = $this->parentWorkflow->children() + ->wherePivot('parent_index', '<', StoredWorkflow::ACTIVE_WORKFLOW_INDEX) + ->get(); + + if ($children->isEmpty()) { + return true; + } + + $childIndices = $children->pluck('pivot.parent_index'); + + $logCount = $this->parentWorkflow->logs() + ->whereIn('index', $childIndices) + ->count(); + + return $logCount >= $childIndices->count(); + } } diff --git a/tests/Unit/ChildWorkflowTest.php b/tests/Unit/ChildWorkflowTest.php index 49da05bf..08bfb83c 100644 --- a/tests/Unit/ChildWorkflowTest.php +++ b/tests/Unit/ChildWorkflowTest.php @@ -10,6 +10,7 @@ use Workflow\ChildWorkflow; use Workflow\Models\StoredWorkflow; use Workflow\Serializers\Serializer; +use Workflow\States\WorkflowCreatedStatus; use Workflow\States\WorkflowRunningStatus; use Workflow\WorkflowStub; @@ -36,4 +37,92 @@ public function testHandleReleasesWhenParentWorkflowIsRunning(): void $this->assertSame(1, $storedParent->logs()->count()); $this->assertSame(WorkflowRunningStatus::class, $storedParent->refresh()->status::class); } + + public function testHandleDoesNotWakeParentWhenSiblingsArePending(): void + { + $parent = WorkflowStub::make(TestWorkflow::class); + $storedParent = StoredWorkflow::findOrFail($parent->id()); + $storedParent->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowCreatedStatus::class, + ]); + + $storedChild1 = StoredWorkflow::create([ + 'class' => TestChildWorkflow::class, + 'arguments' => Serializer::serialize([]), + ]); + $storedChild2 = StoredWorkflow::create([ + 'class' => TestChildWorkflow::class, + 'arguments' => Serializer::serialize([]), + ]); + $storedChild3 = StoredWorkflow::create([ + 'class' => TestChildWorkflow::class, + 'arguments' => Serializer::serialize([]), + ]); + + $storedChild1->parents() + ->attach($storedParent, [ + 'parent_index' => 0, + 'parent_now' => now(), + ]); + $storedChild2->parents() + ->attach($storedParent, [ + 'parent_index' => 1, + 'parent_now' => now(), + ]); + $storedChild3->parents() + ->attach($storedParent, [ + 'parent_index' => 2, + 'parent_now' => now(), + ]); + + // Only the first child completes; two siblings are still pending + $job = new ChildWorkflow(0, now()->toDateTimeString(), $storedChild1, true, $storedParent); + $job->handle(); + + // Log written but parent not dispatched (still in created status) + $this->assertSame(1, $storedParent->logs()->count()); + $this->assertSame(WorkflowCreatedStatus::class, $storedParent->refresh()->status::class); + } + + public function testHandleWakesParentOnLastSiblingCompletion(): void + { + $parent = WorkflowStub::make(TestWorkflow::class); + $storedParent = StoredWorkflow::findOrFail($parent->id()); + $storedParent->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowCreatedStatus::class, + ]); + + $storedChild1 = StoredWorkflow::create([ + 'class' => TestChildWorkflow::class, + 'arguments' => Serializer::serialize([]), + ]); + $storedChild2 = StoredWorkflow::create([ + 'class' => TestChildWorkflow::class, + 'arguments' => Serializer::serialize([]), + ]); + + $storedChild1->parents() + ->attach($storedParent, [ + 'parent_index' => 0, + 'parent_now' => now(), + ]); + $storedChild2->parents() + ->attach($storedParent, [ + 'parent_index' => 1, + 'parent_now' => now(), + ]); + + // First child completes — parent should not be woken + $job1 = new ChildWorkflow(0, now()->toDateTimeString(), $storedChild1, true, $storedParent); + $job1->handle(); + $this->assertSame(WorkflowCreatedStatus::class, $storedParent->refresh()->status::class); + + // Second (last) child completes — parent should be dispatched (pending) + $job2 = new ChildWorkflow(1, now()->toDateTimeString(), $storedChild2, true, $storedParent); + $job2->handle(); + $this->assertSame(2, $storedParent->logs()->count()); + $this->assertNotSame(WorkflowCreatedStatus::class, $storedParent->refresh()->status::class); + } } From 5340b8ad2bb2fef1945b5161ea45401b15e2dbb0 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Mon, 13 Apr 2026 18:30:27 -0500 Subject: [PATCH 2/8] Rename package to durable-workflow/workflow (#378) Rebrand the Composer package name from laravel-workflow/laravel-workflow to durable-workflow/workflow. The `replace` clause keeps the old name resolvable so existing consumers continue to work during the transition. PHP namespace (Workflow\) is unchanged. Co-authored-by: Durable Workflow Co-authored-by: Claude Opus 4.6 --- composer.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a67f5a68..cf3b21d7 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,11 @@ { - "name": "laravel-workflow/laravel-workflow", + "name": "durable-workflow/workflow", "description": "Durable workflow engine that allows users to write long running persistent distributed workflows (orchestrations) in PHP powered by Laravel queues.", "type": "library", "license": "MIT", + "replace": { + "laravel-workflow/laravel-workflow": "self.version" + }, "autoload": { "psr-4": { "Workflow\\": "src/" From 2fa4d3f1fffc8d870e6caef574f99579ab9ce401 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Fri, 24 Apr 2026 10:24:01 -0500 Subject: [PATCH 3/8] Add configurable watchdog toggle (#384) --- src/Providers/WorkflowServiceProvider.php | 4 ++++ src/config/workflows.php | 4 ++++ tests/Unit/Config/WorkflowsConfigTest.php | 3 +++ .../Providers/WorkflowServiceProviderTest.php | 23 +++++++++++++++++++ 4 files changed, 34 insertions(+) diff --git a/src/Providers/WorkflowServiceProvider.php b/src/Providers/WorkflowServiceProvider.php index 2a050857..a7f6bfd9 100644 --- a/src/Providers/WorkflowServiceProvider.php +++ b/src/Providers/WorkflowServiceProvider.php @@ -29,6 +29,10 @@ public function boot(): void $this->commands([ActivityMakeCommand::class, WorkflowMakeCommand::class]); Event::listen(Looping::class, static function (Looping $event): void { + if (! config('workflows.watchdog.enabled', true)) { + return; + } + Watchdog::wake($event->connectionName, $event->queue); }); } diff --git a/src/config/workflows.php b/src/config/workflows.php index 26fc15cb..6d3c312d 100644 --- a/src/config/workflows.php +++ b/src/config/workflows.php @@ -21,6 +21,10 @@ 'prune_age' => '1 month', + 'watchdog' => [ + 'enabled' => env('WORKFLOW_WATCHDOG_ENABLED', true), + ], + 'webhooks_route' => env('WORKFLOW_WEBHOOKS_ROUTE', 'webhooks'), 'webhook_auth' => [ diff --git a/tests/Unit/Config/WorkflowsConfigTest.php b/tests/Unit/Config/WorkflowsConfigTest.php index 184fb5f0..0f550301 100644 --- a/tests/Unit/Config/WorkflowsConfigTest.php +++ b/tests/Unit/Config/WorkflowsConfigTest.php @@ -24,6 +24,9 @@ public function testConfigIsLoaded(): void 'workflow_relationships_table' => 'workflow_relationships', 'serializer' => \Workflow\Serializers\Y::class, 'prune_age' => '1 month', + 'watchdog' => [ + 'enabled' => env('WORKFLOW_WATCHDOG_ENABLED', true), + ], 'webhooks_route' => env('WORKFLOW_WEBHOOKS_ROUTE', 'webhooks'), ]; diff --git a/tests/Unit/Providers/WorkflowServiceProviderTest.php b/tests/Unit/Providers/WorkflowServiceProviderTest.php index 69d871cd..279ecb69 100644 --- a/tests/Unit/Providers/WorkflowServiceProviderTest.php +++ b/tests/Unit/Providers/WorkflowServiceProviderTest.php @@ -119,6 +119,29 @@ public function testLoopingEventThrottlesWake(): void Queue::assertPushed(Watchdog::class, 1); } + public function testLoopingEventSkipsWhenWatchdogIsDisabled(): void + { + Queue::fake(); + Cache::forget('workflow:watchdog'); + Cache::forget('workflow:watchdog:looping'); + + config([ + 'workflows.watchdog.enabled' => false, + ]); + + StoredWorkflow::create([ + 'class' => TestSimpleWorkflow::class, + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowPendingStatus::$name, + 'updated_at' => now() + ->subSeconds(Watchdog::DEFAULT_TIMEOUT + 1), + ]); + + Event::dispatch(new Looping('redis', 'high,default')); + + Queue::assertNotPushed(Watchdog::class); + } + public function testLoopingEventSkipsWhenThrottleAlreadyHeld(): void { Queue::fake(); From 0342b0131ee7e7f94a12fb8c58fddcc2b088fd0d Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Thu, 7 May 2026 09:22:26 -0500 Subject: [PATCH 4/8] Add 'Translate a Book' sponsor link to README (#385) Added a new sponsor link for 'Translate a Book' to the README. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ebf682e7..8a8e60ca 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ The Durable Workflow package is sustained by the community via sponsors and volu - Freispace Resource Scheduling - Hugo Cox +- Translate a Book ## Monitoring From ea877f6ea5db4c38ddafa51dab273c66f9e7d18b Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Mon, 1 Jun 2026 08:14:36 -0500 Subject: [PATCH 5/8] Update sponsors in README (#392) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8a8e60ca..ee493d76 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ $workflow->output(); The Durable Workflow package is sustained by the community via sponsors and volunteers. +- Andriy Karpishyn - Freispace Resource Scheduling - Hugo Cox - Translate a Book From c3f5312e8094bf3415fe519f47ef6b1bf34d4132 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Tue, 30 Jun 2026 19:19:23 -0500 Subject: [PATCH 6/8] Fix getVersion() shifting replay on pre-existing workflow history (#396) --- .github/workflows/php.yml | 16 ++++++++++- src/Traits/Versions.php | 10 +++++++ tests/Feature/VersionWorkflowTest.php | 33 +++++++++++++++++++++++ tests/Unit/Traits/VersionsTest.php | 39 +++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 2934dfbb..84c690bd 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -38,6 +38,13 @@ jobs: with: fetch-depth: 10 + - name: Pin Composer to the 2.x line + # The install step below relies on `--no-blocking`. Pin Composer to the + # latest 2.x release so that option is always present and keeps its current + # semantics regardless of the runner's bundled Composer version (a behaviour + # change would require a new Composer major, which this pin excludes). + run: composer self-update --2 + - name: Validate composer.json and composer.lock run: composer validate --strict @@ -51,7 +58,14 @@ jobs: ${{ runner.os }}-php- - name: Install dependencies - run: composer install --prefer-dist --no-progress + # Laravel 10.x is end-of-life and every remaining 10.x release now carries + # published security advisories. The dev test matrix still pins + # orchestra/testbench ^8 (Laravel 10 only), so Composer's default policy + # blocking refuses to install those releases and resolution fails before + # any test runs. --no-blocking lets the install resolve the latest 10.x for + # the test run; composer.json is unchanged and shipped users are unaffected. + # Composer is pinned to 2.x above so this flag is always available. + run: composer install --prefer-dist --no-progress --no-blocking - name: Check coding style via ECS run: vendor/bin/ecs check diff --git a/src/Traits/Versions.php b/src/Traits/Versions.php index c9655a7e..820a6eed 100644 --- a/src/Traits/Versions.php +++ b/src/Traits/Versions.php @@ -21,6 +21,16 @@ public static function getVersion( $log = self::$context->storedWorkflow->findLogByIndex(self::$context->index); if ($log) { + // Only treat the recorded log as this change's version marker when its + // class matches. A different class means getVersion() was added to a + // workflow whose history was recorded before the change existed. + // Consuming the slot here would shift every later event, so fall back to + // the minimum supported version and leave the index untouched so the + // original event still replays in its recorded position. + if ($log->class !== 'version:' . $changeId) { + return resolve($minSupported); + } + $version = Serializer::unserialize($log->result); if ($version < $minSupported || $version > $maxSupported) { diff --git a/tests/Feature/VersionWorkflowTest.php b/tests/Feature/VersionWorkflowTest.php index 1eb327f8..12956047 100644 --- a/tests/Feature/VersionWorkflowTest.php +++ b/tests/Feature/VersionWorkflowTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature; +use Tests\Fixtures\TestVersionedActivityV1; use Tests\Fixtures\TestVersionedActivityV3; use Tests\Fixtures\TestVersionMinSupportedWorkflow; use Tests\Fixtures\TestVersionWorkflow; @@ -101,6 +102,38 @@ public function testDefaultVersionIsReplayedFromLogs(): void $this->assertSame('v1_result', $output['result1']); } + public function testGetVersionAddedToExistingHistoryDoesNotShiftReplay(): void + { + $workflow = WorkflowStub::make(TestVersionWorkflow::class); + $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); + + // Simulate a workflow whose history was recorded before the getVersion('step-1') + // call was introduced: index 0 holds the first activity's result rather than a + // version marker, so getVersion() must not consume that slot. + $storedWorkflow->logs() + ->create([ + 'index' => 0, + 'now' => now(), + 'class' => TestVersionedActivityV1::class, + 'result' => Serializer::serialize('v1_result'), + ]); + + $workflow->start(); + + while ($workflow->running()); + + $this->assertSame(WorkflowCompletedStatus::class, $workflow->status()); + + $output = $workflow->output(); + + // getVersion() falls back to the default version because the change did not + // exist when this history was recorded... + $this->assertSame(WorkflowStub::DEFAULT_VERSION, $output['version1']); + // ...and because the slot was left untouched, the first activity still reads + // its own recorded result instead of a shifted event. + $this->assertSame('v1_result', $output['result1']); + } + public function testVersionBelowMinSupportedThrowsException(): void { $workflow = WorkflowStub::make(TestVersionMinSupportedWorkflow::class); diff --git a/tests/Unit/Traits/VersionsTest.php b/tests/Unit/Traits/VersionsTest.php index f3174456..9e1ef1b2 100644 --- a/tests/Unit/Traits/VersionsTest.php +++ b/tests/Unit/Traits/VersionsTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Traits; use Mockery; +use Tests\Fixtures\TestVersionedActivityV1; use Tests\Fixtures\TestWorkflow; use Tests\TestCase; use Workflow\Exceptions\VersionNotSupportedException; @@ -55,6 +56,44 @@ public function testLoadsStoredResult(): void $this->assertSame(1, $workflow->logs()->count()); } + public function testReturnsMinSupportedWithoutShiftingIndexForPreVersionHistory(): void + { + $workflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id()); + $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); + $storedWorkflow->logs() + ->create([ + 'index' => 0, + 'now' => WorkflowStub::now(), + 'class' => TestVersionedActivityV1::class, + 'result' => Serializer::serialize('activity_result'), + ]); + $result = null; + + WorkflowStub::setContext([ + 'storedWorkflow' => $storedWorkflow, + 'index' => 0, + 'now' => now(), + 'replaying' => true, + ]); + + WorkflowStub::getVersion('test-change', WorkflowStub::DEFAULT_VERSION, 1) + ->then(static function ($value) use (&$result): void { + $result = $value; + }); + + // A getVersion() call added to a workflow whose history predates it must + // not consume the slot of the first real event: it returns the minimum + // supported version, leaves the index untouched so that event still + // replays in place, and records no version marker. + $this->assertSame(WorkflowStub::DEFAULT_VERSION, $result); + $this->assertSame(0, WorkflowStub::getContext()->index); + $this->assertSame(1, $workflow->logs()->count()); + $this->assertDatabaseMissing('workflow_logs', [ + 'stored_workflow_id' => $workflow->id(), + 'class' => 'version:test-change', + ]); + } + public function testThrowsExceptionWhenVersionBelowMinSupported(): void { $workflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id()); From 530c3764c0e5c90f1ff0eeb1c12417eb12f684b5 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Thu, 9 Jul 2026 15:56:53 -0500 Subject: [PATCH 7/8] Update README shields for package name change (#397) Updated shield links in README to reflect package name change from 'laravel-workflow' to 'durable-workflow'. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ee493d76..ed2ae1bd 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -

GitHub Workflow Status Codecov Packagist Downloads (custom server) - Docs Packagist License

+

GitHub Workflow Status Codecov Packagist Downloads (custom server) + Docs Packagist License

Durable Workflow (formerly Laravel Workflow) is a package for the Laravel web framework that provides tools for defining and managing workflows and activities. A workflow is a series of interconnected activities that are executed in a specific order to achieve a desired result. Activities are individual tasks or pieces of logic that are executed as part of a workflow. From d502e26e91818afcc96164e9962a38d0da453909 Mon Sep 17 00:00:00 2001 From: jakobkollerup <128644226+jakobkollerup@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:54:56 +0200 Subject: [PATCH 8/8] Fix route:cache failing on signal webhook routes (#398) Signal webhook route closures captured the ReflectionMethod instance, so php artisan route:cache failed with "Serialization of 'ReflectionMethod' is not allowed" for any app with a #[Webhook] signal method. Capture the method name instead. --- src/Webhooks.php | 9 +++++---- tests/Unit/WebhooksTest.php | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/Webhooks.php b/src/Webhooks.php index 92dd595c..70e1ea85 100644 --- a/src/Webhooks.php +++ b/src/Webhooks.php @@ -95,18 +95,19 @@ private static function registerSignalWebhooks($workflow, $basePath) foreach (self::getSignalMethods($workflow) as $method) { if (self::hasWebhookAttributeOnMethod($method)) { $slug = Str::kebab(class_basename($workflow)); - $signal = Str::kebab($method->getName()); + $signalMethod = $method->getName(); + $signal = Str::kebab($signalMethod); Route::post( "{$basePath}/signal/{$slug}/{workflowId}/{$signal}", - static function (Request $request, $workflowId) use ($workflow, $method) { + static function (Request $request, $workflowId) use ($workflow, $signalMethod) { $request = self::validateAuth($request); $workflowInstance = WorkflowStub::load($workflowId); $params = self::resolveNamedParameters( $workflow, - $method->getName(), + $signalMethod, $request->except('workflowId') ); - $workflowInstance->{$method->getName()}(...$params); + $workflowInstance->{$signalMethod}(...$params); return response()->json([ 'message' => 'Signal sent', ]); diff --git a/tests/Unit/WebhooksTest.php b/tests/Unit/WebhooksTest.php index 9fd3d455..d30c55ad 100644 --- a/tests/Unit/WebhooksTest.php +++ b/tests/Unit/WebhooksTest.php @@ -391,6 +391,26 @@ public function testSignalUnauthorized(): void 'message' => 'Unauthorized', ]); } + + public function testWebhookRoutesCanBeSerializedForRouteCaching() + { + $webhookRoutes = []; + + foreach (Route::getRoutes() as $route) { + if (str_starts_with((string) $route->getName(), 'workflows.')) { + $webhookRoutes[] = $route; + } + } + + $this->assertNotEmpty($webhookRoutes); + + foreach ($webhookRoutes as $route) { + // Mirrors what `php artisan route:cache` does. Closures must only + // capture serializable values (no ReflectionMethod instances). + $route->prepareForSerialization(); + serialize($route); + } + } } class TestClass