From 1e3f893110fa81e46437e5bf2ff21fb96d1a07f3 Mon Sep 17 00:00:00 2001
From: Durable Workflow
Date: Tue, 1 Sep 2026 09:52:44 +0000
Subject: [PATCH] Streamline PHP SDK first-visit experience
---
.github/workflows/ci.yml | 321 +---
.github/workflows/docs.yml | 48 +-
.../workflows/external-link-diagnostics.yml | 63 -
.../framework-bridges-published-smoke.yml | 1629 -----------------
README.md | 714 +-------
composer.json | 6 +-
docs/portal/frameworks/laravel.md | 7 +-
docs/sdk-reference.md | 685 +++++++
package.json | 7 +-
scripts/check-docs-analytics-browser.mjs | 816 ---------
scripts/check-docs-examples-contract.json | 6 +-
scripts/ci/classify-ci-qualification.py | 306 ----
.../ci/fixtures/docs-links/broken-internal.md | 3 -
.../docs-links/external-dns-failure.md | 3 -
.../ci/fixtures/docs-links/malformed-url.md | 3 -
scripts/ci/test-ci-qualification.py | 454 -----
scripts/ci/test-workflow-trust-boundaries.py | 810 --------
scripts/qualify-docs-analytics-deployment.mjs | 507 -----
...qualify-docs-analytics-deployment.test.mjs | 564 ------
...qualify-quickstart-contract-deployment.mjs | 405 ----
...fy-quickstart-contract-deployment.test.mjs | 405 ----
...ualify-quickstart-release-availability.mjs | 85 -
22 files changed, 787 insertions(+), 7060 deletions(-)
delete mode 100644 .github/workflows/external-link-diagnostics.yml
delete mode 100644 .github/workflows/framework-bridges-published-smoke.yml
create mode 100644 docs/sdk-reference.md
delete mode 100644 scripts/check-docs-analytics-browser.mjs
delete mode 100644 scripts/ci/classify-ci-qualification.py
delete mode 100644 scripts/ci/fixtures/docs-links/broken-internal.md
delete mode 100644 scripts/ci/fixtures/docs-links/external-dns-failure.md
delete mode 100644 scripts/ci/fixtures/docs-links/malformed-url.md
delete mode 100644 scripts/ci/test-ci-qualification.py
delete mode 100644 scripts/ci/test-workflow-trust-boundaries.py
delete mode 100644 scripts/qualify-docs-analytics-deployment.mjs
delete mode 100644 scripts/qualify-docs-analytics-deployment.test.mjs
delete mode 100644 scripts/qualify-quickstart-contract-deployment.mjs
delete mode 100644 scripts/qualify-quickstart-contract-deployment.test.mjs
delete mode 100644 scripts/qualify-quickstart-release-availability.mjs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index eccf705..c76b16d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,11 +13,6 @@ concurrency:
group: ci-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-env:
- # Pull requests on an explicitly identified alternate CI server use focused
- # candidate admission. Removing this opt-in fails safe to the complete gate.
- ALTERNATE_CI_FOCUSED_ADMISSION: 'true'
-
jobs:
action-policy:
name: Central action policy preflight
@@ -49,203 +44,8 @@ jobs:
--target sdk-php
--workflow-directory .github/workflows
- qualification-route:
- name: Resolve qualification route
- runs-on: ubuntu-latest
- timeout-minutes: 3
- outputs:
- route: ${{ steps.classify.outputs.route }}
- route_reason: ${{ steps.classify.outputs.route_reason }}
- categories: ${{ steps.classify.outputs.categories }}
- changed_path_reason: ${{ steps.classify.outputs.changed_path_reason }}
- changed_count: ${{ steps.classify.outputs.changed_count }}
- started_at: ${{ steps.clock.outputs.started_at }}
- steps:
- - name: Record candidate start
- id: clock
- run: echo "started_at=$(date +%s)" >> "$GITHUB_OUTPUT"
- - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- with:
- fetch-depth: 0
- persist-credentials: false
- - name: Resolve portable qualification route
- id: classify
- env:
- QUALIFICATION_BASE_SHA: ${{ github.event.pull_request.base.sha }}
- QUALIFICATION_EVENT_NAME: ${{ github.event_name }}
- QUALIFICATION_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- QUALIFICATION_SERVER_URL: ${{ github.server_url }}
- run: |
- python scripts/ci/classify-ci-qualification.py \
- --root . \
- --server-url "$QUALIFICATION_SERVER_URL" \
- --event-name "$QUALIFICATION_EVENT_NAME" \
- --alternate-ci-focused-admission "${ALTERNATE_CI_FOCUSED_ADMISSION:-}" \
- --base-ref "$QUALIFICATION_BASE_SHA" \
- --head-ref "$QUALIFICATION_HEAD_SHA" \
- --github-output "$GITHUB_OUTPUT"
- - name: Prove qualification routing and privileged workflow contracts
- run: |
- python scripts/ci/test-ci-qualification.py
- python scripts/ci/test-workflow-trust-boundaries.py
- - name: Prove an external outage cannot fail offline qualification
- uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2
- with:
- args: >-
- --no-progress
- --offline
- --include-fragments
- --root-dir .
- scripts/ci/fixtures/docs-links/external-dns-failure.md
- - name: Exercise broken internal link fixture
- id: broken-internal-link
- continue-on-error: true
- uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2
- with:
- args: >-
- --no-progress
- --offline
- --include-fragments
- --root-dir .
- scripts/ci/fixtures/docs-links/broken-internal.md
- - name: Require broken internal links to fail qualification
- env:
- FIXTURE_OUTCOME: ${{ steps.broken-internal-link.outcome }}
- run: test "$FIXTURE_OUTCOME" = failure
- - name: Exercise malformed URL fixture
- id: malformed-url
- continue-on-error: true
- uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2
- with:
- args: >-
- --no-progress
- --offline
- --include-fragments
- --root-dir .
- scripts/ci/fixtures/docs-links/malformed-url.md
- - name: Require malformed URLs to fail qualification
- env:
- FIXTURE_OUTCOME: ${{ steps.malformed-url.outcome }}
- run: test "$FIXTURE_OUTCOME" = failure
-
- focused-candidate:
- name: Focused candidate evidence
- needs: qualification-route
- if: ${{ needs.qualification-route.outputs.route == 'focused' }}
- runs-on: ubuntu-latest
- timeout-minutes: 10
- steps:
- - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- with:
- fetch-depth: 0
- persist-credentials: false
- - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
- with:
- php-version: '8.3'
- tools: composer:v2
- coverage: none
- - name: Validate package structure
- run: composer validate --strict
- - name: Scan the candidate public boundary
- env:
- BASE_SHA: ${{ github.event.pull_request.base.sha }}
- HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- run: |
- if [[ "$BASE_SHA" =~ ^[0-9a-f]{40}$ ]] \
- && [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] \
- && git cat-file -e "${BASE_SHA}^{commit}" \
- && git cat-file -e "${HEAD_SHA}^{commit}"; then
- range="${BASE_SHA}..${HEAD_SHA}"
- elif [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] \
- && git rev-parse --verify --quiet "${HEAD_SHA}^" >/dev/null; then
- range="${HEAD_SHA}^..${HEAD_SHA}"
- else
- echo "Unable to establish a safe public-boundary revision range." >&2
- exit 1
- fi
- PUBLIC_BOUNDARY_GIT_RANGE="$range" scripts/check-public-boundary.sh
- - name: Install change-relevant Composer dependencies
- if: >-
- ${{
- contains(needs.qualification-route.outputs.categories, 'docs')
- || contains(needs.qualification-route.outputs.categories, 'runtime')
- }}
- run: composer install --no-interaction --prefer-dist --no-progress
- - name: Exercise one representative PHP runtime
- if: ${{ contains(needs.qualification-route.outputs.categories, 'runtime') }}
- run: |
- find src tests examples -name '*.php' -print0 | xargs -0 -n1 php -l
- composer test
- composer benchmark-avro-value
- composer check-boundary
- - name: Require relevant regression evidence
- if: ${{ contains(needs.qualification-route.outputs.categories, 'runtime') }}
- env:
- CORPUS_BASE_REF: ${{ github.event.pull_request.base.sha }}
- run: |
- arguments=()
- if [[ "$CORPUS_BASE_REF" =~ ^[0-9a-f]{40}$ ]] && [[ ! "$CORPUS_BASE_REF" =~ ^0+$ ]]; then
- arguments+=(--base-ref "$CORPUS_BASE_REF")
- fi
- python scripts/ci/validate-regression-corpus.py "${arguments[@]}"
- python scripts/ci/test-regression-corpus-policy.py
- - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
- if: >-
- ${{
- contains(needs.qualification-route.outputs.categories, 'docs')
- || contains(needs.qualification-route.outputs.categories, 'docs-browser')
- }}
- with:
- node-version: '24'
- cache: npm
- - name: Install documentation dependencies
- if: >-
- ${{
- contains(needs.qualification-route.outputs.categories, 'docs')
- || contains(needs.qualification-route.outputs.categories, 'docs-browser')
- }}
- run: npm ci
- - name: Exercise focused documentation contracts
- if: ${{ contains(needs.qualification-route.outputs.categories, 'docs') }}
- run: |
- npm run check:docs-examples
- npm run test:quickstart-contract-deployment
- composer docs
- npm run check:docs
- php scripts/check-docs-analytics.php build/site
- - name: Check repository-owned documentation links offline
- if: ${{ contains(needs.qualification-route.outputs.categories, 'docs') }}
- uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2
- with:
- args: >-
- --no-progress
- --offline
- --include-fragments
- --root-dir .
- README.md docs/*.md
- - name: Check rendered documentation links
- if: ${{ contains(needs.qualification-route.outputs.categories, 'docs') }}
- uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2
- with:
- args: >-
- --no-progress
- --offline
- --root-dir build/site
- build/site
- - name: Install responsive documentation browser dependencies
- if: ${{ contains(needs.qualification-route.outputs.categories, 'docs-browser') }}
- run: npx playwright install chromium --with-deps
- - name: Exercise responsive documentation browser evidence
- if: ${{ contains(needs.qualification-route.outputs.categories, 'docs-browser') }}
- run: |
- npm run test:docs-browser-failures
- npm run test:docs-analytics-deployment
- npm run check:docs-analytics-browser -- build/site
- npm run check:docs-browser
regression-corpus:
name: Regression corpus
- needs: qualification-route
- if: ${{ needs.qualification-route.outputs.route == 'complete' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -273,8 +73,6 @@ jobs:
test:
name: PHP ${{ matrix.php }}
- needs: qualification-route
- if: ${{ needs.qualification-route.outputs.route == 'complete' }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
@@ -300,8 +98,6 @@ jobs:
framework-compat:
name: ${{ matrix.framework }} ${{ matrix.version }}
- needs: qualification-route
- if: ${{ needs.qualification-route.outputs.route == 'complete' }}
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
@@ -506,8 +302,6 @@ jobs:
laravel-transition-compat:
name: Laravel ${{ matrix.source }} to service mode
- needs: qualification-route
- if: ${{ needs.qualification-route.outputs.route == 'complete' }}
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
@@ -540,8 +334,6 @@ jobs:
run: scripts/ci/laravel-service-transition-smoke.sh
analyse:
- needs: qualification-route
- if: ${{ needs.qualification-route.outputs.route == 'complete' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -555,8 +347,6 @@ jobs:
- run: composer analyse
docs:
- needs: qualification-route
- if: ${{ needs.qualification-route.outputs.route == 'complete' }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -573,7 +363,6 @@ jobs:
- run: composer install --no-interaction --prefer-dist
- run: npm ci
- run: npm run check:docs-examples
- - run: npm run test:quickstart-contract-deployment
- run: composer docs
- run: npm run check:docs
- run: php scripts/check-docs-analytics.php build/site
@@ -586,27 +375,7 @@ jobs:
--include-fragments
--root-dir .
README.md docs/*.md
- - name: Check rendered documentation links
- uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2
- with:
- args: >-
- --no-progress
- --offline
- --root-dir build/site
- build/site
- - run: npx playwright install chromium --with-deps
- - name: Test missing-resource browser evidence
- run: npm run test:docs-browser-failures
- - name: Test deployed Cloudflare transport qualification
- run: npm run test:docs-analytics-deployment
- - name: Check Cloudflare analytics in Chromium
- run: npm run check:docs-analytics-browser -- build/site
- - name: Check portal and API layouts in Chromium
- run: npm run check:docs-browser
-
package-smoke:
- needs: qualification-route
- if: ${{ needs.qualification-route.outputs.route == 'complete' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -635,14 +404,13 @@ jobs:
target-branch-qualification:
name: Target branch qualification
- if: ${{ always() }}
+ if: ${{ always() && github.server_url == 'https://github.com' }}
needs:
- action-policy
- - qualification-route
- - focused-candidate
- regression-corpus
- test
- framework-compat
+ - laravel-transition-compat
- analyse
- docs
- package-smoke
@@ -654,85 +422,20 @@ jobs:
env:
ACTION_POLICY_RESULT: ${{ needs.action-policy.result }}
run: test "$ACTION_POLICY_RESULT" = success
-
- - name: Require a recognized qualification route
- env:
- ROUTE: ${{ needs.qualification-route.outputs.route }}
- ROUTE_RESULT: ${{ needs.qualification-route.result }}
- run: |
- test "$ROUTE_RESULT" = success
- case "$ROUTE" in
- focused|complete|sentinel) ;;
- *) echo "Unrecognized or unavailable qualification route: $ROUTE" >&2; exit 1 ;;
- esac
- - name: Require selected candidate or target evidence
+ - name: Require every product check
env:
- ROUTE: ${{ needs.qualification-route.outputs.route }}
- FOCUSED_RESULT: ${{ needs.focused-candidate.result }}
+ CORPUS_RESULT: ${{ needs.regression-corpus.result }}
TEST_RESULT: ${{ needs.test.result }}
FRAMEWORK_RESULT: ${{ needs.framework-compat.result }}
- CORPUS_RESULT: ${{ needs.regression-corpus.result }}
+ LARAVEL_TRANSITION_RESULT: ${{ needs.laravel-transition-compat.result }}
ANALYSIS_RESULT: ${{ needs.analyse.result }}
DOCS_RESULT: ${{ needs.docs.result }}
PACKAGE_RESULT: ${{ needs.package-smoke.result }}
run: |
- if [ "$ROUTE" = focused ]; then
- test "$FOCUSED_RESULT" = success
- test "$TEST_RESULT" = skipped
- test "$FRAMEWORK_RESULT" = skipped
- test "$CORPUS_RESULT" = skipped
- test "$ANALYSIS_RESULT" = skipped
- test "$DOCS_RESULT" = skipped
- test "$PACKAGE_RESULT" = skipped
- echo "Focused candidate evidence succeeded; complete compatibility is deferred to the landed SHA."
- elif [ "$ROUTE" = complete ]; then
- test "$FOCUSED_RESULT" = skipped
- test "$TEST_RESULT" = success
- test "$FRAMEWORK_RESULT" = success
- test "$CORPUS_RESULT" = success
- test "$ANALYSIS_RESULT" = success
- test "$DOCS_RESULT" = success
- test "$PACKAGE_RESULT" = success
- echo "Complete GitHub target qualification succeeded."
- elif [ "$ROUTE" = sentinel ]; then
- test "$FOCUSED_RESULT" = skipped
- test "$TEST_RESULT" = skipped
- test "$FRAMEWORK_RESULT" = skipped
- test "$CORPUS_RESULT" = skipped
- test "$ANALYSIS_RESULT" = skipped
- test "$DOCS_RESULT" = skipped
- test "$PACKAGE_RESULT" = skipped
- echo "Alternate-CI target sentinel succeeded; GitHub owns landed-SHA qualification."
- else
- exit 1
- fi
- - name: Record candidate-to-decision timing
- if: ${{ always() && needs.qualification-route.result == 'success' }}
- env:
- CATEGORIES: ${{ needs.qualification-route.outputs.categories }}
- CHANGED_COUNT: ${{ needs.qualification-route.outputs.changed_count }}
- PATH_REASON: ${{ needs.qualification-route.outputs.changed_path_reason }}
- ROUTE: ${{ needs.qualification-route.outputs.route }}
- ROUTE_REASON: ${{ needs.qualification-route.outputs.route_reason }}
- STARTED_AT: ${{ needs.qualification-route.outputs.started_at }}
- run: |
- elapsed_seconds=$(( $(date +%s) - STARTED_AT ))
- before_elapsed_seconds_lower_bound=300
- after_elapsed_seconds=$elapsed_seconds
- printf '%s\n' \
- "route=$ROUTE route_reason=$ROUTE_REASON categories=${CATEGORIES:-none} changed_count=$CHANGED_COUNT path_reason=$PATH_REASON" \
- "before_elapsed_seconds_lower_bound=$before_elapsed_seconds_lower_bound" \
- "after_elapsed_seconds=$after_elapsed_seconds" \
- "focused_cells=PHP-8.3 complete_cells=PHP-8.1,PHP-8.2,PHP-8.3,PHP-8.4,Laravel-9,Laravel-10,Laravel-11,Laravel-12,Laravel-13,Symfony-6.4,Symfony-7,Symfony-8"
- if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
- {
- echo "### Qualification timing"
- echo
- echo "| Measurement | Seconds |"
- echo "| --- | ---: |"
- echo "| Representative pre-change candidate (observed lower bound) | >${before_elapsed_seconds_lower_bound} |"
- echo "| This candidate to aggregate decision | ${after_elapsed_seconds} |"
- echo
- echo "Route: \`${ROUTE}\`; focused PHP/framework cells: PHP 8.3 only; complete landed-SHA cells: 4 PHP + 8 framework."
- } >> "$GITHUB_STEP_SUMMARY"
- fi
+ test "$CORPUS_RESULT" = success
+ test "$TEST_RESULT" = success
+ test "$FRAMEWORK_RESULT" = success
+ test "$LARAVEL_TRANSITION_RESULT" = success
+ test "$ANALYSIS_RESULT" = success
+ test "$DOCS_RESULT" = success
+ test "$PACKAGE_RESULT" = success
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 9695a8a..8ad03f3 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -10,15 +10,11 @@ on:
- '.eleventy.cjs'
- 'scripts/check-docs-*'
- 'scripts/finalize-api-reference.php'
- - 'scripts/qualify-quickstart-*'
- - 'scripts/qualify-docs-analytics*'
- 'README.md'
- 'composer.json'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/docs.yml'
- schedule:
- - cron: '41 * * * *'
workflow_dispatch:
permissions:
@@ -31,8 +27,6 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
- outputs:
- release_published: ${{ steps.release-availability.outputs.release_published }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
@@ -46,12 +40,7 @@ jobs:
cache: npm
- run: composer install --no-interaction --prefer-dist
- run: npm ci
- - name: Resolve the exact published portal release
- id: release-availability
- if: github.server_url == 'https://github.com'
- run: node scripts/qualify-quickstart-release-availability.mjs
- run: npm run check:docs-examples
- - run: npm run test:quickstart-contract-deployment
- name: Check repository-owned documentation links offline
uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2
with:
@@ -90,10 +79,6 @@ jobs:
- run: npx playwright install chromium --with-deps
- name: Test missing-resource browser evidence
run: npm run test:docs-browser-failures
- - name: Test deployed Cloudflare transport qualification
- run: npm run test:docs-analytics-deployment
- - name: Check Cloudflare analytics in Chromium
- run: npm run check:docs-analytics-browser -- build/site
- name: Check portal and API layouts in Chromium
run: npm run check:docs-browser
- name: Bind Pages artifact to the exact source revision
@@ -114,8 +99,7 @@ jobs:
deploy:
if: >-
github.server_url == 'https://github.com' &&
- github.ref == 'refs/heads/main' &&
- needs.build.outputs.release_published == 'true'
+ github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-latest
permissions:
@@ -128,33 +112,3 @@ jobs:
steps:
- id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5
-
- qualify-deployment:
- if: github.server_url == 'https://github.com' && github.ref == 'refs/heads/main'
- needs: deploy
- runs-on: ubuntu-latest
- timeout-minutes: 15
- permissions:
- actions: read
- contents: read
- steps:
- - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
- with:
- php-version: '8.3'
- tools: composer:v2
- coverage: none
- - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
- with:
- node-version: '24'
- cache: npm
- - run: npm ci
- - run: npx playwright install chromium --with-deps
- - name: Qualify deployed quickstart references
- env:
- GITHUB_TOKEN: ${{ github.token }}
- run: npm run qualify:quickstart-contract-deployment
- - name: Qualify deployed analytics transports
- run: >-
- npm run qualify:docs-analytics-deployment --
- --source-revision "${{ github.sha }}"
diff --git a/.github/workflows/external-link-diagnostics.yml b/.github/workflows/external-link-diagnostics.yml
deleted file mode 100644
index 4bc2537..0000000
--- a/.github/workflows/external-link-diagnostics.yml
+++ /dev/null
@@ -1,63 +0,0 @@
-name: External documentation link diagnostics
-
-on:
- schedule:
- - cron: '37 6 * * 1'
- workflow_dispatch:
-
-permissions:
- contents: read
-
-concurrency:
- group: sdk-php-external-link-diagnostics
- cancel-in-progress: false
-
-jobs:
- diagnose:
- name: Record external link responses (non-blocking)
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- with:
- persist-credentials: false
- - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
- with:
- php-version: '8.3'
- tools: composer:v2
- coverage: none
- - run: composer install --no-interaction --prefer-dist --no-progress
- - run: composer docs -- --template="$PWD/.phpdoc/template"
- - name: Record exact external GET statuses and URLs
- id: external-links
- continue-on-error: true
- uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2
- with:
- args: >-
- --no-progress
- --method get
- --verbose
- --format json
- --output external-link-diagnostics.json
- --include ^https?://
- --exclude https://php.durable-workflow.com/
- README.md docs/*.md build/api/**/*.html build/api/**/*.css
- - name: Bind the request method to the diagnostic report
- if: always()
- run: |
- if test -s external-link-diagnostics.json; then
- jq '{method: "GET", lychee: .}' \
- external-link-diagnostics.json > external-link-diagnostics-with-method.json
- else
- printf '%s\n' \
- '{"method":"GET","lychee":null,"error":"Lychee did not produce a report"}' \
- > external-link-diagnostics-with-method.json
- fi
- - name: Retain external link diagnostics
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
- with:
- name: sdk-php-external-link-diagnostics-${{ github.run_id }}
- path: external-link-diagnostics-with-method.json
- if-no-files-found: error
- retention-days: 30
diff --git a/.github/workflows/framework-bridges-published-smoke.yml b/.github/workflows/framework-bridges-published-smoke.yml
deleted file mode 100644
index 701b4c8..0000000
--- a/.github/workflows/framework-bridges-published-smoke.yml
+++ /dev/null
@@ -1,1629 +0,0 @@
-name: Published framework bridge smoke
-
-on:
- workflow_dispatch:
- inputs:
- sdk_version:
- description: Exact published Packagist version, such as 2.0.0 or 2.0.0-rc.9
- required: true
- type: string
- server_version:
- description: Exact current Server version declared by the published SDK
- required: true
- type: string
- python_sdk_version:
- description: >-
- Exact synchronized Python SDK product version, such as 2.0.0 or
- 2.0.0-rc.9
- required: true
- type: string
-
-permissions:
- contents: read
-
-jobs:
- framework-service-mode:
- name: ${{ matrix.framework }} / ${{ matrix.runtime }} published workflow
- if: github.ref == 'refs/heads/main'
- runs-on: ubuntu-latest
- timeout-minutes: 25
- environment: published-service-smoke
- strategy:
- fail-fast: false
- matrix:
- include:
- - framework: laravel
- runtime: standalone-server
- transport: job-local-server
- - framework: laravel
- runtime: managed-cloud
- transport: protected-cloud
- - framework: symfony
- runtime: standalone-server
- transport: job-local-server
- - framework: symfony
- runtime: managed-cloud
- transport: protected-cloud
- steps:
- - name: Validate the exact published SDK version
- env:
- SDK_VERSION: ${{ inputs.sdk_version }}
- run: |
- if [[ ! "$SDK_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]]; then
- echo "sdk_version must be an exact stable, alpha, beta, or release-candidate version" >&2
- exit 1
- fi
-
- - name: Validate the exact current Server version
- env:
- SERVER_VERSION: ${{ inputs.server_version }}
- run: |
- if [[ ! "$SERVER_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]]; then
- echo "server_version must be an exact stable, alpha, beta, or release-candidate version" >&2
- exit 1
- fi
-
- - name: Validate the exact published Python SDK version
- env:
- PYTHON_SDK_VERSION: ${{ inputs.python_sdk_version }}
- run: |
- if [[ ! "$PYTHON_SDK_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]]; then
- echo "python_sdk_version must be an exact stable, alpha, beta, or release-candidate product version" >&2
- exit 1
- fi
- python_sdk_pep440_version="${PYTHON_SDK_VERSION/-alpha./a}"
- python_sdk_pep440_version="${python_sdk_pep440_version/-beta./b}"
- python_sdk_pep440_version="${python_sdk_pep440_version/-rc./rc}"
- echo "PYTHON_SDK_PEP440_VERSION=$python_sdk_pep440_version" >> "$GITHUB_ENV"
-
- - name: Validate managed Cloud configuration
- if: matrix.transport == 'protected-cloud'
- env:
- DURABLE_WORKFLOW_SERVER_URL: ${{ secrets.DURABLE_WORKFLOW_SERVER_URL }}
- DURABLE_WORKFLOW_NAMESPACE: ${{ secrets.DURABLE_WORKFLOW_NAMESPACE }}
- DURABLE_WORKFLOW_CLIENT_TOKEN: ${{ secrets.DURABLE_WORKFLOW_CLIENT_TOKEN }}
- DURABLE_WORKFLOW_WORKER_TOKEN: ${{ secrets.DURABLE_WORKFLOW_WORKER_TOKEN }}
- run: |
- required=(
- DURABLE_WORKFLOW_SERVER_URL
- DURABLE_WORKFLOW_NAMESPACE
- DURABLE_WORKFLOW_CLIENT_TOKEN
- DURABLE_WORKFLOW_WORKER_TOKEN
- )
- missing=()
- for secret_name in "${required[@]}"; do
- if [ -z "${!secret_name:-}" ]; then
- missing+=("$secret_name")
- fi
- done
- if [ "${#missing[@]}" -ne 0 ]; then
- printf 'Managed Cloud qualification is missing protected secret(s):' >&2
- printf ' %s' "${missing[@]}" >&2
- printf '\n' >&2
- exit 1
- fi
- if [ "$DURABLE_WORKFLOW_CLIENT_TOKEN" = "$DURABLE_WORKFLOW_WORKER_TOKEN" ]; then
- echo 'Managed Cloud client and worker credentials must be distinct.' >&2
- exit 1
- fi
-
- - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
- with:
- php-version: '8.4'
- tools: composer:v2
- coverage: none
-
- - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- with:
- python-version: '3.12'
-
- - name: Install and verify the exact published Python activity worker
- env:
- PYTHON_SDK_VERSION: ${{ inputs.python_sdk_version }}
- run: |
- python -m pip install --disable-pip-version-check \
- "durable-workflow==${PYTHON_SDK_PEP440_VERSION}"
- python - <<'PY'
- import importlib.metadata
- import os
-
- requested = os.environ["PYTHON_SDK_VERSION"]
- expected = os.environ["PYTHON_SDK_PEP440_VERSION"]
- actual = importlib.metadata.version("durable-workflow")
- if actual != expected:
- raise RuntimeError(
- "Installed Python SDK distribution version "
- f"{actual} does not match requested product version {requested} "
- f"(PEP 440: {expected})."
- )
- print(f"requested_python_sdk_version={requested}")
- print(f"requested_python_sdk_pep440_version={expected}")
- print(f"installed_python_sdk_version={actual}")
- with open(
- os.environ["GITHUB_STEP_SUMMARY"],
- "a",
- encoding="utf-8",
- ) as summary:
- summary.write("## Published Python SDK identity\n\n")
- summary.write(
- "| Requested product version | Exact PEP 440 version "
- "| Installed distribution version |\n"
- )
- summary.write("| --- | --- | --- |\n")
- summary.write(f"| `{requested}` | `{expected}` | `{actual}` |\n")
- PY
-
- - name: Resolve the immutable published SDK release
- env:
- SDK_VERSION: ${{ inputs.sdk_version }}
- run: |
- composer show durable-workflow/sdk "$SDK_VERSION" --all --format=json \
- > "$RUNNER_TEMP/sdk-release.json"
- php <<'PHP'
- "$RUNNER_TEMP/framework-application"
- if [ "$FRAMEWORK" = laravel ]; then
- composer create-project laravel/laravel:^13.0 "$application" --no-interaction --prefer-dist
- else
- composer create-project symfony/skeleton:^8.0 "$application" --no-interaction --prefer-dist
- composer require --working-dir="$application" symfony/console symfony/framework-bundle \
- --no-interaction --prefer-dist
- fi
- composer require --working-dir="$application" "durable-workflow/sdk:${SDK_VERSION}" \
- --no-interaction --prefer-dist
-
- - name: Verify the installed SDK release identity
- env:
- SDK_VERSION: ${{ inputs.sdk_version }}
- SERVER_VERSION: ${{ inputs.server_version }}
- run: |
- application="$(cat "$RUNNER_TEMP/framework-application")"
- SDK_LOCK_FILE="$application/composer.lock" php <<'PHP'
- ($package['name'] ?? null) === 'durable-workflow/sdk',
- ));
- if (count($packages) !== 1 || ($packages[0]['version'] ?? null) !== $version) {
- throw new RuntimeException('Installed SDK package identity or version does not match the request.');
- }
- $installedReferences = [];
- foreach (['source', 'dist'] as $kind) {
- $reference = $packages[0][$kind]['reference'] ?? null;
- if ($reference === null) {
- continue;
- }
- if (!is_string($reference) || preg_match('/\A[0-9a-f]{40}\z/i', $reference) !== 1) {
- throw new RuntimeException("Installed {$kind} reference is not an immutable commit.");
- }
- $expectedReference = $release[$kind]['reference'] ?? null;
- if (!is_string($expectedReference) || strtolower($reference) !== strtolower($expectedReference)) {
- throw new RuntimeException("Installed {$kind} reference does not match the requested release.");
- }
- $installedReferences[] = strtolower($reference);
- }
- if ($installedReferences === []) {
- throw new RuntimeException('Installed SDK has no immutable source or distribution reference.');
- }
- $packageManifest = json_decode(
- file_get_contents(dirname(getenv('SDK_LOCK_FILE')).'/vendor/durable-workflow/sdk/composer.json'),
- true,
- flags: JSON_THROW_ON_ERROR,
- );
- if (($packageManifest['extra']['durable-workflow']['supported-server-versions'] ?? null) !== getenv('SERVER_VERSION')) {
- throw new RuntimeException('Requested Server version does not match the published SDK compatibility baseline.');
- }
- PHP
-
- - name: Configure Laravel through auto-discovery and vendor publish
- if: matrix.framework == 'laravel'
- run: |
- application="$(cat "$RUNNER_TEMP/framework-application")"
- cd "$application"
- mkdir -p app/Actions app/Activities app/Support app/Workflows
- tee app/Support/PublishedRoleCredentialProbe.php >/dev/null <<'PHP'
- getenv($name) !== false,
- '_ENV' => array_key_exists($name, $_ENV),
- '_SERVER' => array_key_exists($name, $_SERVER),
- 'laravel_env' => class_exists(Env::class)
- ? Env::get($name) !== null
- : null,
- ];
- }
- $role = getenv('DURABLE_WORKFLOW_PROCESS_ROLE');
- file_put_contents($log, json_encode([
- 'stage' => $stage,
- 'role_is_client' => $role === 'client',
- 'role_is_worker' => $role === 'worker',
- 'presence' => $presence,
- ], JSON_THROW_ON_ERROR).PHP_EOL, FILE_APPEND | LOCK_EX);
- }
- }
- PHP
- tee app/Providers/PublishedRoleCredentialProbeProvider.php >/dev/null <<'PHP'
- /dev/null <<'PHP'
- /dev/null <<'PHP'
- /dev/null <<'PHP'
- prefix->value() === '') {
- throw new \LogicException('Laravel did not inject the workflow dependency.');
- }
-
- $version = $context->getVersion('published-framework-greeting', 1, 1);
-
- $released = $context->waitCondition(
- static fn (): bool => $context->signals('published.release') !== [],
- key: 'published-laravel-release',
- timeout: 60,
- );
- if (!$released) {
- return ['greetings' => ['published Laravel condition wait timed out'], 'version' => $version];
- }
-
- $restart = $context->all([
- static fn () => $context->activity('laravel.greet', ["{$name} restart"]),
- static fn () => $context->parallel([
- static fn () => $context->childWorkflow('laravel.child-greeting', [$name]),
- static fn () => $context->sleep(30),
- ]),
- ]);
-
- $activities = $context->all([
- static fn () => $context->activity('laravel.greet', [$name]),
- static fn () => $context->activity('laravel.greet', ["{$name} activity two"]),
- ]);
- $children = $context->parallel([
- static fn () => $context->childWorkflow('laravel.child-greeting', ["{$name} child one"]),
- static fn () => $context->childWorkflow('laravel.child-greeting', ["{$name} child two"]),
- ]);
-
- $failureActivity = null;
- try {
- $context->all([
- static fn () => $context->activity('laravel.greet', ["{$name} failure sibling"]),
- static fn () => $context->activity(
- 'laravel.fail',
- [$name],
- ['retry_policy' => ['max_attempts' => 1]],
- ),
- ]);
- } catch (ActivityFailed $exception) {
- $failureActivity = $exception->activityType;
- }
-
- return compact('restart', 'activities', 'children', 'failureActivity', 'version');
- }
-
- #[Signal('published.release')]
- public function release(): void {}
- }
- PHP
- tee app/Workflows/PublishedGreetingChildWorkflow.php >/dev/null <<'PHP'
- /dev/null <<'PHP'
- prefix->value() === '') {
- throw new \LogicException('Laravel did not inject the saga workflow dependency.');
- }
-
- return $context->saga()->run(static function (Saga $saga) use ($context, $tripId): array {
- $flight = $context->activity('python.reserve-flight', [$tripId]);
- $saga->addCompensation('python.cancel-flight', [$tripId, $flight]);
-
- $hotel = $context->activity('python.reserve-hotel', [$tripId]);
- $saga->addCompensation('python.cancel-hotel', [$tripId, $hotel]);
-
- $context->activity(
- 'python.charge-card',
- [$tripId],
- ['retry_policy' => ['max_attempts' => 1]],
- );
-
- return compact('flight', 'hotel');
- });
- }
- }
- PHP
- tee app/Activities/PublishedGreetingActivities.php >/dev/null <<'PHP'
- prefix->value()}, {$name}";
- }
-
- #[Activity('laravel.fail')]
- public function fail(ActivityContext $context, string $name): never
- {
- throw new \RuntimeException("intentional qualification failure for {$name}");
- }
- }
- PHP
- tee published-saga-activities.py >/dev/null <<'PY'
- import asyncio
- import os
- import signal
-
- from durable_workflow import Client, Worker, activity
-
-
- @activity.defn(name="python.reserve-flight")
- def reserve_flight(trip_id: str) -> str:
- return f"flight:{trip_id}"
-
-
- @activity.defn(name="python.reserve-hotel")
- def reserve_hotel(trip_id: str) -> str:
- return f"hotel:{trip_id}"
-
-
- @activity.defn(name="python.charge-card")
- def charge_card(trip_id: str) -> None:
- raise RuntimeError(f"card declined for {trip_id}")
-
-
- @activity.defn(name="python.cancel-hotel")
- def cancel_hotel(trip_id: str, reservation_id: str) -> str:
- return f"cancelled:{reservation_id}"
-
-
- @activity.defn(name="python.cancel-flight")
- async def cancel_flight(trip_id: str, reservation_id: str) -> str:
- await asyncio.sleep(10)
- return f"cancelled:{reservation_id}"
-
-
- async def main() -> None:
- async with Client(
- os.environ["DURABLE_WORKFLOW_RUNTIME_URL"],
- namespace=os.environ["DURABLE_WORKFLOW_NAMESPACE"],
- token=os.environ["DURABLE_WORKFLOW_WORKER_TOKEN"],
- ) as client:
- worker = Worker(
- client,
- task_queue=os.environ["DURABLE_WORKFLOW_TASK_QUEUE"],
- workflows=[],
- activities=[
- reserve_flight,
- reserve_hotel,
- charge_card,
- cancel_hotel,
- cancel_flight,
- ],
- )
- loop = asyncio.get_running_loop()
- for signal_name in (signal.SIGINT, signal.SIGTERM):
- loop.add_signal_handler(
- signal_name,
- lambda: asyncio.create_task(worker.stop()),
- )
- await worker.run()
-
-
- if __name__ == "__main__":
- asyncio.run(main())
- PY
- tee app/Actions/StartPublishedGreeting.php >/dev/null <<'PHP'
- workflows->start(
- \App\Workflows\PublishedGreetingWorkflow::class,
- ['Ada'],
- workflowId: $workflowId,
- )->result(timeoutSeconds: 90, pollIntervalSeconds: 1);
- }
- }
- PHP
- tee -a routes/console.php >/dev/null <<'PHP'
-
- Artisan::command('durable-workflow:published-greeting', function (): void {
- $client = app(\DurableWorkflow\Client::class);
- $actualVersion = $client->clusterInfo()->version;
- if ($actualVersion !== env('QUALIFIED_SERVER_VERSION')) {
- throw new \RuntimeException("Published endpoint is Server {$actualVersion}; expected ".env('QUALIFIED_SERVER_VERSION').'.');
- }
- $workflowId = env('DURABLE_WORKFLOW_WORKFLOW_ID');
- $phase = env('PUBLISHED_WORKFLOW_PHASE');
- $workflows = app(\DurableWorkflow\Bridge\Laravel\LaravelWorkflowClientInterface::class);
- if ($phase === 'start') {
- $handle = $workflows->start(
- \App\Workflows\PublishedGreetingWorkflow::class,
- ['Ada'],
- workflowId: $workflowId,
- );
- $deadline = microtime(true) + 30;
- do {
- $execution = $client->describeWorkflow($workflowId);
- if ($execution->status === 'waiting') {
- break;
- }
- usleep(250_000);
- } while (microtime(true) < $deadline);
- if ($execution->status !== 'waiting' || !is_string($execution->runId)) {
- throw new \RuntimeException('Published Laravel workflow did not open its condition wait.');
- }
- try {
- $handle->signal('published.release');
- } catch (\DurableWorkflow\Exception\SignalFailed $exception) {
- throw new \RuntimeException(
- 'Published Laravel release signal failed: '.json_encode([
- 'status' => $exception->status,
- 'reason' => $exception->reason,
- 'details' => $exception->details,
- ], JSON_THROW_ON_ERROR),
- previous: $exception,
- );
- }
-
- $deadline = microtime(true) + 20;
- do {
- $history = $client->workflowHistory($workflowId, $execution->runId);
- $events = $history['events'] ?? $history['history_events'] ?? [];
- $restartEvents = array_values(array_filter(
- is_array($events) ? $events : [],
- static function (mixed $event): bool {
- if (!is_array($event) || !is_array($event['payload'] ?? null)) {
- return false;
- }
- $path = $event['payload']['parallel_group_path'] ?? null;
- $outer = is_array($path) && is_array($path[0] ?? null) ? $path[0] : [];
-
- return ($outer['parallel_group_kind'] ?? null) === 'mixed'
- && ($outer['parallel_group_size'] ?? null) === 3;
- },
- ));
- $restartTypes = array_column($restartEvents, 'event_type');
- if (in_array('ActivityCompleted', $restartTypes, true)
- && in_array('TimerScheduled', $restartTypes, true)
- && !in_array('TimerFired', $restartTypes, true)
- ) {
- return;
- }
- usleep(250_000);
- } while (microtime(true) < $deadline);
- throw new \RuntimeException('Published Laravel workflow did not expose a partially completed mixed group before restart.');
- }
- if ($phase !== 'finish') {
- throw new \RuntimeException('Published Laravel workflow phase must be start or finish.');
- }
-
- $execution = $client->describeWorkflow($workflowId);
- if (!is_string($execution->runId) || $execution->runId === '') {
- throw new \RuntimeException('Published Laravel workflow has no run identity for cold replay.');
- }
- $handle = $workflows->handle(
- \App\Workflows\PublishedGreetingWorkflow::class,
- $workflowId,
- $execution->runId,
- );
- $result = $handle->result(timeoutSeconds: 90, pollIntervalSeconds: 1);
- if ($result !== [
- 'restart' => ['hello from Laravel, Ada restart', ['child Ada', null]],
- 'activities' => [
- 'hello from Laravel, Ada',
- 'hello from Laravel, Ada activity two',
- ],
- 'children' => ['child Ada child one', 'child Ada child two'],
- 'failureActivity' => 'laravel.fail',
- 'version' => 1,
- ]) {
- throw new \RuntimeException('Published Laravel workflow returned an unexpected upgraded result.');
- }
- $history = $client->workflowHistory($workflowId, $execution->runId);
- $events = $history['events'] ?? $history['history_events'] ?? [];
- $markers = array_values(array_filter(
- is_array($events) ? $events : [],
- static fn (mixed $event): bool => is_array($event)
- && ($event['event_type'] ?? $event['type'] ?? null) === 'VersionMarkerRecorded',
- ));
- if (count($markers) !== 1) {
- throw new \RuntimeException('Published Laravel workflow did not retain exactly one version marker.');
- }
- $payload = is_array($markers[0]['payload'] ?? null) ? $markers[0]['payload'] : [];
- if (($payload['change_id'] ?? null) !== 'published-framework-greeting'
- || ($payload['version'] ?? null) !== 1) {
- throw new \RuntimeException('Published Laravel workflow changed its durable version decision.');
- }
- $scheduled = array_values(array_filter(
- is_array($events) ? $events : [],
- static fn (mixed $event): bool => is_array($event)
- && is_array($event['payload'] ?? null)
- && in_array($event['event_type'] ?? $event['type'] ?? null, [
- 'ActivityScheduled',
- 'ChildWorkflowScheduled',
- 'TimerScheduled',
- ], true)
- && is_array($event['payload']['parallel_group_path'] ?? null),
- ));
- $groups = [];
- $sequences = [];
- foreach ($scheduled as $event) {
- $payload = $event['payload'];
- $path = $payload['parallel_group_path'];
- $outer = $path[0] ?? null;
- if (!is_array($outer)) {
- throw new \RuntimeException('Published Laravel history contains an invalid parallel path.');
- }
- $groups[$outer['parallel_group_id']][] = [
- 'event_type' => $event['event_type'] ?? $event['type'] ?? null,
- 'outer' => $outer,
- 'path_depth' => count($path),
- ];
- $sequences[] = $payload['sequence'] ?? null;
- }
- $groupShapes = array_map(static function (array $members): array {
- $first = $members[0];
- $depths = array_column($members, 'path_depth');
- sort($depths);
-
- return [
- 'kind' => $first['outer']['parallel_group_kind'] ?? null,
- 'size' => $first['outer']['parallel_group_size'] ?? null,
- 'types' => array_column($members, 'event_type'),
- 'depths' => $depths,
- ];
- }, $groups);
- $matches = static fn (array $shape, string $kind, array $types, array $depths): bool =>
- $shape['kind'] === $kind
- && $shape['size'] === count($types)
- && $shape['types'] === $types
- && $shape['depths'] === $depths;
- $activityGroups = array_values(array_filter(
- $groupShapes,
- static fn (array $shape): bool => $matches(
- $shape,
- 'activity',
- ['ActivityScheduled', 'ActivityScheduled'],
- [1, 1],
- ),
- ));
- $childGroups = array_values(array_filter(
- $groupShapes,
- static fn (array $shape): bool => $matches(
- $shape,
- 'child',
- ['ChildWorkflowScheduled', 'ChildWorkflowScheduled'],
- [1, 1],
- ),
- ));
- $mixedGroups = array_values(array_filter(
- $groupShapes,
- static fn (array $shape): bool => $matches(
- $shape,
- 'mixed',
- ['ActivityScheduled', 'ChildWorkflowScheduled', 'TimerScheduled'],
- [1, 2, 2],
- ),
- ));
- $failures = array_values(array_filter(
- is_array($events) ? $events : [],
- static fn (mixed $event): bool => is_array($event)
- && ($event['event_type'] ?? $event['type'] ?? null) === 'ActivityFailed'
- && ($event['payload']['activity_type'] ?? null) === 'laravel.fail'
- && is_array($event['payload']['parallel_group_path'] ?? null),
- ));
- if (count($scheduled) !== 9
- || count(array_unique($sequences, SORT_REGULAR)) !== 9
- || count($activityGroups) !== 2
- || count($childGroups) !== 1
- || count($mixedGroups) !== 1
- || count($failures) !== 1
- ) {
- throw new \RuntimeException('Published Laravel history did not prove every parallel qualification cell exactly once.');
- }
- });
-
- Artisan::command('durable-workflow:published-saga', function (): void {
- $client = app(\DurableWorkflow\Client::class);
- $workflowId = env('DURABLE_WORKFLOW_SAGA_ID');
- $phase = env('PUBLISHED_SAGA_PHASE');
- $workflows = app(\DurableWorkflow\Bridge\Laravel\LaravelWorkflowClientInterface::class);
- if ($phase === 'start') {
- $workflows->start(
- \App\Workflows\PublishedSagaWorkflow::class,
- ['trip-published'],
- workflowId: $workflowId,
- );
- $deadline = microtime(true) + 45;
- do {
- $execution = $client->describeWorkflow($workflowId);
- if (is_string($execution->runId) && $execution->runId !== '') {
- $history = $client->workflowHistory($workflowId, $execution->runId);
- $events = $history['events'] ?? $history['history_events'] ?? [];
- $scheduledFlightCompensation = array_values(array_filter(
- is_array($events) ? $events : [],
- static fn (mixed $event): bool => is_array($event)
- && ($event['event_type'] ?? $event['type'] ?? null) === 'ActivityScheduled'
- && ($event['payload']['activity_type'] ?? null) === 'python.cancel-flight',
- ));
- $completedFlightCompensation = array_values(array_filter(
- is_array($events) ? $events : [],
- static fn (mixed $event): bool => is_array($event)
- && ($event['event_type'] ?? $event['type'] ?? null) === 'ActivityCompleted'
- && ($event['payload']['activity_type'] ?? null) === 'python.cancel-flight',
- ));
- if (count($scheduledFlightCompensation) === 1
- && $completedFlightCompensation === []) {
- return;
- }
- }
- usleep(250_000);
- } while (microtime(true) < $deadline);
- throw new \RuntimeException('Published saga did not reach its in-flight final compensation.');
- }
- if ($phase !== 'finish') {
- throw new \RuntimeException('Published saga phase must be start or finish.');
- }
-
- $execution = $client->describeWorkflow($workflowId);
- if (!is_string($execution->runId) || $execution->runId === '') {
- throw new \RuntimeException('Published saga has no run identity after worker restart.');
- }
- $handle = $workflows->handle(
- \App\Workflows\PublishedSagaWorkflow::class,
- $workflowId,
- $execution->runId,
- );
- try {
- $handle->result(timeoutSeconds: 90, pollIntervalSeconds: 1);
- throw new \RuntimeException('Published saga silently completed after its forward failure.');
- } catch (\DurableWorkflow\Exception\WorkflowFailed $failure) {
- if ($failure->failureType !== \DurableWorkflow\Exception\ActivityFailed::class
- || !str_contains($failure->getMessage(), 'card declined')) {
- throw new \RuntimeException(
- 'Published saga did not preserve its typed forward failure.',
- previous: $failure,
- );
- }
- }
-
- $history = $client->workflowHistory($workflowId, $execution->runId);
- $events = $history['events'] ?? $history['history_events'] ?? [];
- $activityTypes = static function (string $eventType) use ($events): array {
- return array_values(array_map(
- static fn (array $event): string => (string) $event['payload']['activity_type'],
- array_filter(
- is_array($events) ? $events : [],
- static fn (mixed $event): bool => is_array($event)
- && ($event['event_type'] ?? $event['type'] ?? null) === $eventType
- && is_string($event['payload']['activity_type'] ?? null)
- && str_starts_with($event['payload']['activity_type'], 'python.'),
- ),
- ));
- };
- $scheduled = $activityTypes('ActivityScheduled');
- $completed = $activityTypes('ActivityCompleted');
- $failed = $activityTypes('ActivityFailed');
- if ($scheduled !== [
- 'python.reserve-flight',
- 'python.reserve-hotel',
- 'python.charge-card',
- 'python.cancel-hotel',
- 'python.cancel-flight',
- ] || $completed !== [
- 'python.reserve-flight',
- 'python.reserve-hotel',
- 'python.cancel-hotel',
- 'python.cancel-flight',
- ] || $failed !== ['python.charge-card']) {
- throw new \RuntimeException('Published cross-language saga history changed order or duplicated a compensation.');
- }
- fwrite(STDOUT, json_encode([
- 'runtime' => env('RUNTIME_TARGET'),
- 'scheduled' => $scheduled,
- 'completed' => $completed,
- 'failed' => $failed,
- 'worker_restart_during_compensation' => true,
- ], JSON_THROW_ON_ERROR).PHP_EOL);
- });
-
- Artisan::command('durable-workflow:published-fake', function (): void {
- $fake = \DurableWorkflow\Bridge\Laravel\Facades\DurableWorkflow::fake()
- ->setWorkflowResult('published-laravel-fake', [
- 'restart' => ['hello from Laravel, Ada restart', ['child Ada', null]],
- 'activities' => [
- 'hello from Laravel, Ada',
- 'hello from Laravel, Ada activity two',
- ],
- 'children' => ['child Ada child one', 'child Ada child two'],
- 'failureActivity' => 'laravel.fail',
- 'version' => 1,
- ]);
- $result = app(\App\Actions\StartPublishedGreeting::class)(
- 'published-laravel-fake',
- );
- if ($result !== [
- 'restart' => ['hello from Laravel, Ada restart', ['child Ada', null]],
- 'activities' => [
- 'hello from Laravel, Ada',
- 'hello from Laravel, Ada activity two',
- ],
- 'children' => ['child Ada child one', 'child Ada child two'],
- 'failureActivity' => 'laravel.fail',
- 'version' => 1,
- ]) {
- throw new \RuntimeException('Published Laravel fake returned an unexpected result.');
- }
- $fake->assertWorkflowStarted(
- \App\Workflows\PublishedGreetingWorkflow::class,
- ['Ada'],
- workflowId: 'published-laravel-fake',
- );
- $fake->assertResultRequested('published-laravel-fake');
- $fake->setWorkflowResult('published-laravel-saga-fake', [
- 'status' => 'compensated',
- 'compensations' => ['python.cancel-hotel', 'python.cancel-flight'],
- ]);
- $sagaResult = $fake->start(
- \App\Workflows\PublishedSagaWorkflow::class,
- ['trip-published'],
- workflowId: 'published-laravel-saga-fake',
- )->result();
- if (($sagaResult['status'] ?? null) !== 'compensated') {
- throw new \RuntimeException('Published Laravel fake returned an unexpected saga result.');
- }
- $fake->assertWorkflowStarted(
- \App\Workflows\PublishedSagaWorkflow::class,
- ['trip-published'],
- workflowId: 'published-laravel-saga-fake',
- );
- $fake->assertResultRequested('published-laravel-saga-fake');
- });
- PHP
- php artisan vendor:publish --tag=durable-workflow-config --force
- sed -i "/'handlers' => \[/a\ App\\\\Workflows\\\\PublishedGreetingWorkflow::class,\n App\\\\Workflows\\\\PublishedGreetingChildWorkflow::class,\n App\\\\Workflows\\\\PublishedSagaWorkflow::class,\n App\\\\Activities\\\\PublishedGreetingActivities::class," config/durable-workflow.php
-
- - name: Prove the published Laravel fake without a runtime
- if: matrix.framework == 'laravel'
- run: |
- application="$(cat "$RUNNER_TEMP/framework-application")"
- cd "$application"
- php artisan durable-workflow:published-fake
-
- - name: Configure Symfony Bundle and autowired handlers
- if: matrix.framework == 'symfony'
- run: |
- application="$(cat "$RUNNER_TEMP/framework-application")"
- cd "$application"
- mkdir -p src/Workflow src/Activity config/packages
- sed -i "/return \[/a\ DurableWorkflow\\\\Bridge\\\\Symfony\\\\DurableWorkflowBundle::class => ['all' => true]," config/bundles.php
- tee config/packages/durable_workflow.yaml >/dev/null <<'YAML'
- durable_workflow:
- endpoint: '%env(DURABLE_WORKFLOW_RUNTIME_URL)%'
- namespace: '%env(DURABLE_WORKFLOW_NAMESPACE)%'
- task_queue: '%env(DURABLE_WORKFLOW_TASK_QUEUE)%'
- credentials:
- control_token: '%env(default::DURABLE_WORKFLOW_CLIENT_TOKEN)%'
- worker_token: '%env(default::DURABLE_WORKFLOW_WORKER_TOKEN)%'
- handlers:
- - App\Workflow\PublishedGreetingWorkflow
- - App\Activity\PublishedGreetingActivities
- YAML
- tee src/Workflow/PublishedGreetingWorkflow.php >/dev/null <<'PHP'
- getVersion('published-framework-greeting', 1, 1);
-
- $released = $context->waitCondition(
- static fn (): bool => $context->signals('published.release') !== [],
- key: 'published-symfony-release',
- timeout: 60,
- );
- if (!$released) {
- return ['greeting' => 'published Symfony condition wait timed out'];
- }
-
- $greetings = $context->parallel([
- static fn () => $context->activity('published.symfony.greet', [$name]),
- static fn () => $context->activity('published.symfony.greet', ["{$name} again"]),
- ]);
-
- return compact('greetings', 'version');
- }
-
- #[Signal('published.release')]
- public function release(): void {}
- }
- PHP
- tee src/Activity/PublishedGreetingActivities.php >/dev/null <<'PHP'
- /dev/null 2>&1 || true
- docker network rm "$runtime_network" >/dev/null 2>&1 || true
- }
- trap cleanup EXIT
-
- docker network create "$runtime_network" >/dev/null
- docker run --detach \
- --name "$mysql_container" \
- --network "$runtime_network" \
- --tmpfs /var/lib/mysql:rw,noexec,nosuid,size=1024m \
- --env MYSQL_DATABASE=durable_workflow \
- --env MYSQL_USER=workflow \
- --env MYSQL_PASSWORD="$mysql_password" \
- --env MYSQL_ROOT_PASSWORD="$mysql_root_password" \
- mysql:8.0.43 >/dev/null
- docker run --detach \
- --name "$redis_container" \
- --network "$runtime_network" \
- --tmpfs /data:rw,noexec,nosuid,size=256m \
- redis:7-alpine >/dev/null
-
- mysql_ready=false
- redis_ready=false
- for _attempt in $(seq 1 60); do
- if docker exec "$mysql_container" \
- mysqladmin ping --host=127.0.0.1 --user=root \
- --password="$mysql_root_password" --silent >/dev/null 2>&1; then
- mysql_ready=true
- fi
- if [ "$(docker exec "$redis_container" redis-cli ping 2>/dev/null || true)" = PONG ]; then
- redis_ready=true
- fi
- if [ "$mysql_ready" = true ] && [ "$redis_ready" = true ]; then
- break
- fi
- sleep 1
- done
- if [ "$mysql_ready" != true ] || [ "$redis_ready" != true ]; then
- echo 'Standalone Server dependencies did not become ready.' >&2
- docker logs "$mysql_container" >&2 || true
- docker logs "$redis_container" >&2 || true
- exit 1
- fi
-
- server_environment=(
- --env APP_ENV=local
- --env DW_SERVER_KEY="$server_key"
- --env DB_CONNECTION=mysql
- --env DB_HOST="$mysql_container"
- --env DB_PORT=3306
- --env DB_DATABASE=durable_workflow
- --env DB_USERNAME=workflow
- --env DB_PASSWORD="$mysql_password"
- --env REDIS_HOST="$redis_container"
- --env QUEUE_CONNECTION=redis
- --env CACHE_STORE=redis
- --env DW_AUTH_DRIVER=token
- --env DW_OPERATOR_TOKEN="$client_token"
- --env DW_WORKER_TOKEN="$worker_token"
- --env DW_AUTH_BACKWARD_COMPATIBLE=false
- )
-
- if ! docker run \
- --name "$bootstrap_container" \
- --network "$runtime_network" \
- "${server_environment[@]}" \
- "$server_image" server-bootstrap; then
- echo 'Standalone Server bootstrap and migrations failed.' >&2
- docker logs "$bootstrap_container" >&2 || true
- exit 1
- fi
- docker rm "$bootstrap_container" >/dev/null
-
- docker run --detach \
- --name "$server_container" \
- --network "$runtime_network" \
- --publish 127.0.0.1::8080 \
- "${server_environment[@]}" \
- "$server_image" >/dev/null
- published_port="$(docker port "$server_container" 8080/tcp | awk -F: 'END {print $NF}')"
- if [ -z "$published_port" ]; then
- echo 'Standalone Server did not publish its workflow API port.' >&2
- exit 1
- fi
- runtime_url="http://127.0.0.1:${published_port}"
-
- workflow_ready=false
- for _attempt in $(seq 1 60); do
- if curl --fail --silent --show-error \
- "$runtime_url/api/ready" >"$RUNNER_TEMP/standalone-ready.json"; then
- if php -r '
- $ready = json_decode(file_get_contents($argv[1]), true, flags: JSON_THROW_ON_ERROR);
- exit(($ready["status"] ?? null) === "ready" ? 0 : 1);
- ' "$RUNNER_TEMP/standalone-ready.json"; then
- workflow_ready=true
- break
- fi
- fi
- if ! docker inspect --format '{{.State.Running}}' "$server_container" 2>/dev/null | grep -Fxq true; then
- break
- fi
- sleep 1
- done
- if [ "$workflow_ready" != true ]; then
- echo 'Standalone Server did not report workflow readiness at /api/ready.' >&2
- cat "$RUNNER_TEMP/standalone-ready.json" >&2 2>/dev/null || true
- docker logs "$server_container" >&2 || true
- exit 1
- fi
-
- {
- printf 'DURABLE_WORKFLOW_RUNTIME_URL=%s\n' "$runtime_url"
- printf 'DURABLE_WORKFLOW_NAMESPACE=default\n'
- printf 'DURABLE_WORKFLOW_CLIENT_TOKEN=%s\n' "$client_token"
- printf 'DURABLE_WORKFLOW_WORKER_TOKEN=%s\n' "$worker_token"
- printf 'STANDALONE_RUNTIME_NETWORK=%s\n' "$runtime_network"
- printf 'STANDALONE_MYSQL_CONTAINER=%s\n' "$mysql_container"
- printf 'STANDALONE_REDIS_CONTAINER=%s\n' "$redis_container"
- printf 'STANDALONE_SERVER_CONTAINER=%s\n' "$server_container"
- } >> "$GITHUB_ENV"
- trap - EXIT
-
- - name: Prepare the framework runtime qualification
- run: |
- tee "$RUNNER_TEMP/framework-runtime.sh" >/dev/null <<'BASH'
- #!/usr/bin/env bash
- set -euo pipefail
-
- case "${RUNTIME_TARGET}:${SELECTED_TRANSPORT}" in
- standalone-server:job-local-server|managed-cloud:protected-cloud) ;;
- *)
- echo "Runtime ${RUNTIME_TARGET} cannot use transport ${SELECTED_TRANSPORT}." >&2
- exit 1
- ;;
- esac
-
- required=(
- DURABLE_WORKFLOW_RUNTIME_URL
- DURABLE_WORKFLOW_NAMESPACE
- DURABLE_WORKFLOW_CLIENT_TOKEN
- DURABLE_WORKFLOW_WORKER_TOKEN
- )
- missing=()
- for variable in "${required[@]}"; do
- if [ -z "${!variable:-}" ]; then
- missing+=("$variable")
- fi
- done
- if [ "${#missing[@]}" -ne 0 ]; then
- printf 'Published runtime configuration is incomplete; missing value(s):' >&2
- printf ' %s' "${missing[@]}" >&2
- printf '\n' >&2
- exit 1
- fi
- if [ "$DURABLE_WORKFLOW_CLIENT_TOKEN" = "$DURABLE_WORKFLOW_WORKER_TOKEN" ]; then
- echo 'Published runtime client and worker credentials must be distinct.' >&2
- exit 1
- fi
-
- application="$(cat "$RUNNER_TEMP/framework-application")"
- cd "$application"
- SDK_LOCK_FILE="$PWD/composer.lock" php <<'PHP'
- ($candidate['name'] ?? null) === 'durable-workflow/sdk',
- ))[0] ?? null;
- if (!is_array($package)) {
- throw new RuntimeException('The installed Durable Workflow package identity is unavailable.');
- }
- fwrite(STDOUT, json_encode([
- 'installed_sdk_version' => $package['version'] ?? null,
- 'installed_sdk_source_reference' => $package['source']['reference'] ?? null,
- 'installed_sdk_dist_reference' => $package['dist']['reference'] ?? null,
- ], JSON_THROW_ON_ERROR).PHP_EOL);
- PHP
- queue="php-${FRAMEWORK}-${RUNTIME_TARGET}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
- workflow_id="php-${FRAMEWORK}-${RUNTIME_TARGET}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
- saga_id="${workflow_id}-saga"
- export DURABLE_WORKFLOW_TASK_QUEUE="$queue"
- export DURABLE_WORKFLOW_WORKFLOW_ID="$workflow_id"
- export DURABLE_WORKFLOW_SAGA_ID="$saga_id"
- export DURABLE_WORKFLOW_TYPE="published.${FRAMEWORK}.greeter"
-
- if [ "$FRAMEWORK" = laravel ]; then
- env -u DURABLE_WORKFLOW_TOKEN \
- -u DURABLE_WORKFLOW_CLIENT_TOKEN \
- -u DURABLE_WORKFLOW_WORKER_TOKEN \
- -u DURABLE_WORKFLOW_PROCESS_ROLE \
- -u DURABLE_WORKFLOW_PROCESS_TOKEN \
- php artisan config:cache
- DURABLE_WORKFLOW_CONFIG_CACHE="$PWD/bootstrap/cache/config.php" php <<'PHP'
- str_starts_with($line, $name.'='),
- )) !== 0;
- }
- fwrite(STDOUT, json_encode([
- 'stage' => 'dotenv-file',
- 'credential_presence' => $configured,
- ], JSON_THROW_ON_ERROR).PHP_EOL);
- if (in_array(true, $configured, true)) {
- throw new RuntimeException('Fresh Laravel .env contains a Durable Workflow credential variable.');
- }
- PHP
- laravel_client_token="$DURABLE_WORKFLOW_CLIENT_TOKEN"
- laravel_worker_token="$DURABLE_WORKFLOW_WORKER_TOKEN"
- unset \
- DURABLE_WORKFLOW_TOKEN \
- DURABLE_WORKFLOW_CLIENT_TOKEN \
- DURABLE_WORKFLOW_WORKER_TOKEN \
- DURABLE_WORKFLOW_PROCESS_ROLE \
- DURABLE_WORKFLOW_PROCESS_TOKEN
- export DURABLE_WORKFLOW_ROLE_PROBE_LOG="$RUNNER_TEMP/laravel-role-presence-${RUNTIME_TARGET}.jsonl"
- : > "$DURABLE_WORKFLOW_ROLE_PROBE_LOG"
- fi
-
- if [ "$FRAMEWORK" = symfony ]; then
- tee durable-client.php >/dev/null <<'PHP'
- boot();
- $client = $kernel->getContainer()->get(WorkflowClientInterface::class);
- if (!$client instanceof WorkflowClientInterface || !$client instanceof Client) {
- throw new RuntimeException('Framework did not provide the injectable workflow client.');
- }
- $actualVersion = $client->clusterInfo()->version;
- if ($actualVersion !== getenv('QUALIFIED_SERVER_VERSION')) {
- throw new RuntimeException("Published endpoint is Server {$actualVersion}; expected ".getenv('QUALIFIED_SERVER_VERSION').'.');
- }
- $workflowId = (string) getenv('DURABLE_WORKFLOW_WORKFLOW_ID');
- $phase = getenv('PUBLISHED_WORKFLOW_PHASE');
- if ($phase === 'start') {
- $client->startWorkflow(
- (string) getenv('DURABLE_WORKFLOW_TYPE'),
- $workflowId,
- (string) getenv('DURABLE_WORKFLOW_TASK_QUEUE'),
- ['published framework'],
- );
- $deadline = microtime(true) + 30;
- do {
- $execution = $client->describeWorkflow($workflowId);
- if ($execution->status === 'waiting') {
- exit(0);
- }
- usleep(250_000);
- } while (microtime(true) < $deadline);
- throw new RuntimeException('Published Symfony workflow did not open its condition wait.');
- }
- if ($phase !== 'finish') {
- throw new RuntimeException('Published Symfony workflow phase must be start or finish.');
- }
-
- $execution = $client->describeWorkflow($workflowId);
- if (!is_string($execution->runId) || $execution->runId === '') {
- throw new RuntimeException('Published Symfony workflow has no run identity for cold replay.');
- }
- $handle = $client->workflowHandle($workflowId, $execution->runId);
- try {
- $handle->signal('published.release');
- } catch (\DurableWorkflow\Exception\SignalFailed $exception) {
- throw new RuntimeException(
- 'Published Symfony release signal failed: '.json_encode([
- 'status' => $exception->status,
- 'reason' => $exception->reason,
- 'details' => $exception->details,
- ], JSON_THROW_ON_ERROR),
- previous: $exception,
- );
- }
- $result = $handle->result(timeoutSeconds: 90, pollIntervalSeconds: 1);
- if ($result !== [
- 'greetings' => ['hello, published framework', 'hello, published framework again'],
- 'version' => 1,
- ]) {
- throw new RuntimeException('Published framework workflow returned an unexpected upgraded result.');
- }
- $history = $client->workflowHistory($workflowId, $execution->runId);
- $events = $history['events'] ?? $history['history_events'] ?? [];
- $markers = array_values(array_filter(
- is_array($events) ? $events : [],
- static fn (mixed $event): bool => is_array($event)
- && ($event['event_type'] ?? $event['type'] ?? null) === 'VersionMarkerRecorded',
- ));
- if (count($markers) !== 1) {
- throw new RuntimeException('Published Symfony workflow did not retain exactly one version marker.');
- }
- $payload = is_array($markers[0]['payload'] ?? null) ? $markers[0]['payload'] : [];
- if (($payload['change_id'] ?? null) !== 'published-framework-greeting'
- || ($payload['version'] ?? null) !== 1) {
- throw new RuntimeException('Published Symfony workflow changed its durable version decision.');
- }
- $parallel = array_values(array_filter(
- is_array($events) ? $events : [],
- static fn (mixed $event): bool => is_array($event)
- && is_array($event['payload'] ?? null)
- && in_array($event['event_type'] ?? $event['type'] ?? null, ['ActivityScheduled', 'ActivityCompleted'], true)
- && ($event['payload']['parallel_group_kind'] ?? null) === 'activity',
- ));
- $parallelPayloads = array_column($parallel, 'payload');
- $parallelIds = array_values(array_unique(array_column($parallelPayloads, 'parallel_group_id')));
- $parallelIndexes = array_column($parallelPayloads, 'parallel_group_index');
- sort($parallelIndexes);
- if (count($parallel) < 4
- || count($parallelIds) !== 1
- || $parallelIndexes !== [0, 0, 1, 1]
- || array_filter($parallelPayloads, static fn (array $entry): bool => !is_array($entry['parallel_group_path'] ?? null)) !== []
- ) {
- throw new RuntimeException('Published Symfony history did not retain its parallel group/path diagnostics.');
- }
- PHP
- fi
-
- cleanup_workers() {
- if [ -n "${worker_pid:-}" ]; then
- kill -TERM "$worker_pid" 2>/dev/null || true
- fi
- if [ -n "${python_worker_pid:-}" ]; then
- kill -TERM "$python_worker_pid" 2>/dev/null || true
- fi
- }
-
- start_worker() {
- local worker_log="$1"
- if [ "$FRAMEWORK" = laravel ]; then
- env \
- -u DURABLE_WORKFLOW_TOKEN \
- -u DURABLE_WORKFLOW_CLIENT_TOKEN \
- -u DURABLE_WORKFLOW_WORKER_TOKEN \
- DURABLE_WORKFLOW_PROCESS_ROLE=worker \
- DURABLE_WORKFLOW_PROCESS_TOKEN="$laravel_worker_token" \
- php laravel-role-launch.php durable-workflow:worker >"$worker_log" 2>&1 &
- else
- env -u DURABLE_WORKFLOW_CLIENT_TOKEN php bin/console durable-workflow:worker >"$worker_log" 2>&1 &
- fi
- worker_pid=$!
- trap cleanup_workers EXIT
- if [ "$FRAMEWORK" = laravel ]; then
- local ready=false
- for _attempt in $(seq 1 30); do
- if grep -Fq 'Registered and polling:' "$worker_log"; then
- ready=true
- break
- fi
- if ! kill -0 "$worker_pid" 2>/dev/null; then
- break
- fi
- sleep 1
- done
- if [ "$ready" != true ] \
- || ! grep -Fq "queue=${queue}" "$worker_log" \
- || ! grep -Fq 'workflows=[' "$worker_log" \
- || ! grep -Fq 'laravel.greeting' "$worker_log" \
- || ! grep -Fq 'laravel.child-greeting' "$worker_log" \
- || ! grep -Fq 'laravel.compensated-trip' "$worker_log" \
- || ! grep -Fq 'activities=[' "$worker_log" \
- || ! grep -Fq 'laravel.greet' "$worker_log" \
- || ! grep -Fq 'laravel.fail' "$worker_log" \
- || ! grep -Fq 'credential_role=worker' "$worker_log"; then
- echo "Laravel worker did not register the qualification handlers and task queue ${queue}." >&2
- cat "$worker_log" >&2
- exit 1
- fi
- fi
- }
-
- stop_worker() {
- local worker_log="$1"
- kill -TERM "$worker_pid" 2>/dev/null || true
- local worker_status=0
- wait "$worker_pid" || worker_status=$?
- worker_pid=''
- if grep -Eq 'worker\.shutdown_failed|HTTP[[:space:]]+403|403[[:space:]]+Forbidden' "$worker_log"; then
- echo 'Framework worker reported an authorization or graceful-shutdown failure.' >&2
- cat "$worker_log" >&2
- exit 1
- fi
- if [ "$worker_status" -ne 0 ]; then
- echo "Framework worker exited with status $worker_status." >&2
- cat "$worker_log" >&2
- exit "$worker_status"
- fi
- }
-
- start_python_worker() {
- env \
- -u DURABLE_WORKFLOW_TOKEN \
- -u DURABLE_WORKFLOW_CLIENT_TOKEN \
- DURABLE_WORKFLOW_WORKER_TOKEN="$laravel_worker_token" \
- python published-saga-activities.py >python-saga-worker.log 2>&1 &
- python_worker_pid=$!
- trap cleanup_workers EXIT
- sleep 2
- if ! kill -0 "$python_worker_pid" 2>/dev/null; then
- echo 'Published Python saga activity worker exited before qualification.' >&2
- cat python-saga-worker.log >&2
- exit 1
- fi
- }
-
- stop_python_worker() {
- kill -TERM "$python_worker_pid" 2>/dev/null || true
- local python_status=0
- wait "$python_worker_pid" || python_status=$?
- python_worker_pid=''
- if [ "$python_status" -ne 0 ]; then
- echo "Published Python saga activity worker exited with status $python_status." >&2
- cat python-saga-worker.log >&2
- exit "$python_status"
- fi
- }
-
- run_client_phase() {
- local phase="$1"
- if [ "$FRAMEWORK" = laravel ]; then
- env \
- -u DURABLE_WORKFLOW_TOKEN \
- -u DURABLE_WORKFLOW_CLIENT_TOKEN \
- -u DURABLE_WORKFLOW_WORKER_TOKEN \
- DURABLE_WORKFLOW_PROCESS_ROLE=client \
- DURABLE_WORKFLOW_PROCESS_TOKEN="$laravel_client_token" \
- PUBLISHED_WORKFLOW_PHASE="$phase" \
- php laravel-role-launch.php durable-workflow:published-greeting
- else
- PUBLISHED_WORKFLOW_PHASE="$phase" env -u DURABLE_WORKFLOW_WORKER_TOKEN php durable-client.php
- fi
- }
-
- run_saga_phase() {
- local phase="$1"
- env \
- -u DURABLE_WORKFLOW_TOKEN \
- -u DURABLE_WORKFLOW_CLIENT_TOKEN \
- -u DURABLE_WORKFLOW_WORKER_TOKEN \
- DURABLE_WORKFLOW_PROCESS_ROLE=client \
- DURABLE_WORKFLOW_PROCESS_TOKEN="$laravel_client_token" \
- PUBLISHED_SAGA_PHASE="$phase" \
- php laravel-role-launch.php durable-workflow:published-saga
- }
-
- start_worker worker-initial.log
- run_client_phase start
- stop_worker worker-initial.log
-
- if [ "$FRAMEWORK" = laravel ]; then
- workflow_source='app/Workflows/PublishedGreetingWorkflow.php'
- else
- workflow_source='src/Workflow/PublishedGreetingWorkflow.php'
- fi
- sed -i \
- "s/getVersion('published-framework-greeting', 1, 1)/getVersion('published-framework-greeting', 1, 2)/" \
- "$workflow_source"
- if ! grep -Fq "getVersion('published-framework-greeting', 1, 2)" "$workflow_source"; then
- echo 'Published workflow code upgrade did not raise the supported maximum.' >&2
- exit 1
- fi
-
- start_worker worker-upgraded.log
- run_client_phase finish
- stop_worker worker-upgraded.log
-
- if [ "$FRAMEWORK" = laravel ]; then
- start_python_worker
- start_worker worker-saga-initial.log
- run_saga_phase start
- stop_worker worker-saga-initial.log
-
- start_worker worker-saga-restarted.log
- run_saga_phase finish
- stop_worker worker-saga-restarted.log
- stop_python_worker
- fi
-
- if [ "$FRAMEWORK" = laravel ]; then
- DURABLE_WORKFLOW_ROLE_PROBE_LOG="$DURABLE_WORKFLOW_ROLE_PROBE_LOG" php <<'PHP'
- json_decode($line, true, flags: JSON_THROW_ON_ERROR),
- file(getenv('DURABLE_WORKFLOW_ROLE_PROBE_LOG'), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES),
- );
- $observed = [];
- foreach ($entries as $entry) {
- fwrite(STDOUT, json_encode($entry, JSON_THROW_ON_ERROR).PHP_EOL);
- $role = ($entry['role_is_client'] ?? false) ? 'client' : 'worker';
- $stage = $entry['stage'] ?? '';
- $presence = $entry['presence'] ?? [];
- foreach (['DURABLE_WORKFLOW_TOKEN', 'DURABLE_WORKFLOW_CLIENT_TOKEN', 'DURABLE_WORKFLOW_WORKER_TOKEN'] as $name) {
- if (($presence[$name]['getenv'] ?? null) !== false) {
- throw new RuntimeException("Laravel {$role} {$stage} retained ambient {$name}.");
- }
- }
- foreach (['DURABLE_WORKFLOW_PROCESS_ROLE', 'DURABLE_WORKFLOW_PROCESS_TOKEN'] as $name) {
- if (($presence[$name]['getenv'] ?? null) !== true) {
- throw new RuntimeException("Laravel {$role} {$stage} lost its explicit {$name} handoff.");
- }
- }
- $observed["{$role}:{$stage}"] = true;
- }
- foreach (['client', 'worker'] as $role) {
- foreach (['shell-entry', 'before-bootstrap', 'after-bootstrap'] as $stage) {
- if (!isset($observed["{$role}:{$stage}"])) {
- throw new RuntimeException("Laravel {$role} did not report credential presence at {$stage}.");
- }
- }
- }
- fwrite(STDOUT, json_encode([
- 'laravel_role_probe_complete' => true,
- 'entry_count' => count($entries),
- ], JSON_THROW_ON_ERROR).PHP_EOL);
- PHP
- fi
- trap - EXIT
- BASH
- chmod +x "$RUNNER_TEMP/framework-runtime.sh"
-
- - name: Complete the published workflow against standalone Server
- if: matrix.transport == 'job-local-server'
- env:
- FRAMEWORK: ${{ matrix.framework }}
- QUALIFIED_SERVER_VERSION: ${{ inputs.server_version }}
- RUNTIME_TARGET: ${{ matrix.runtime }}
- SELECTED_TRANSPORT: ${{ matrix.transport }}
- run: "$RUNNER_TEMP/framework-runtime.sh"
-
- - name: Complete the published workflow against managed Cloud
- if: matrix.transport == 'protected-cloud'
- env:
- DURABLE_WORKFLOW_RUNTIME_URL: ${{ secrets.DURABLE_WORKFLOW_SERVER_URL }}
- DURABLE_WORKFLOW_NAMESPACE: ${{ secrets.DURABLE_WORKFLOW_NAMESPACE }}
- DURABLE_WORKFLOW_CLIENT_TOKEN: ${{ secrets.DURABLE_WORKFLOW_CLIENT_TOKEN }}
- DURABLE_WORKFLOW_WORKER_TOKEN: ${{ secrets.DURABLE_WORKFLOW_WORKER_TOKEN }}
- FRAMEWORK: ${{ matrix.framework }}
- QUALIFIED_SERVER_VERSION: ${{ inputs.server_version }}
- RUNTIME_TARGET: ${{ matrix.runtime }}
- SELECTED_TRANSPORT: ${{ matrix.transport }}
- run: "$RUNNER_TEMP/framework-runtime.sh"
-
- - name: Stop the standalone Server and isolated state
- if: always() && matrix.transport == 'job-local-server'
- run: |
- docker rm -f \
- "$STANDALONE_SERVER_CONTAINER" \
- "$STANDALONE_REDIS_CONTAINER" \
- "$STANDALONE_MYSQL_CONTAINER" >/dev/null 2>&1 || true
- docker network rm "$STANDALONE_RUNTIME_NETWORK" >/dev/null 2>&1 || true
diff --git a/README.md b/README.md
index 6777e7a..c726aea 100644
--- a/README.md
+++ b/README.md
@@ -1,161 +1,61 @@
# Durable Workflow PHP SDK
-The first-party, framework-neutral PHP SDK for applications and remote workers
-that connect to a standalone [Durable Workflow server](https://github.com/durable-workflow/server).
-It targets PHP 8.1 or newer and does not require Laravel or the embedded
-`durable-workflow/workflow` engine.
-
-## Choose the PHP execution model
-
-- **Laravel adoption:** start with the [ownership-first transition
- guide](https://php.durable-workflow.com/frameworks/laravel/) when moving a
- Laravel Workflow v1 application to v2 embedded or service mode, or when
- moving embedded v2 to service mode. Laravel 9 through 13 are supported on
- both v2 destinations.
-- **Plain PHP service mode:** follow the quickstart below when a framework-neutral
- application and remote worker connect to Durable Workflow Cloud or a
- self-hosted Server.
-- **Symfony service mode:** use the [Symfony bridge](#symfony-service-mode) from
- this SDK for autowired remote handlers and a managed console worker.
-- **Embedded Laravel workflows:** use
- [`durable-workflow/workflow`](https://php.durable-workflow.com/frameworks/laravel/)
- when the Laravel application itself should own durable state and execute
- through Laravel queues. That is a different deployment model, not a
- prerequisite for this SDK.
-
-## Plain PHP quickstart
-
-Create an empty Composer project and install the current published package:
+
+
+
+
+
+
+
+The first-party PHP client and worker SDK for
+[Durable Workflow Cloud](https://cloud.durable-workflow.com/early-access) and
+self-hosted [Durable Workflow Server](https://github.com/durable-workflow/server).
+Use it from plain PHP, Laravel, or Symfony to run durable workflows outside the
+application process while keeping framework-native configuration, dependency
+injection, commands, logging, and tests.
+
+## Install
```bash
-mkdir durable-php-quickstart
-cd durable-php-quickstart
-composer init --name=acme/durable-php-quickstart --no-interaction
-composer require 'durable-workflow/sdk:^2.0'
+composer require durable-workflow/sdk:^2.0
```
-The stable 2.0 package declares its verified Server baseline in package
-metadata. Earlier 2.0 prereleases and pre-1.0 SDK releases remain historical
-rather than alternate supported baselines.
+The SDK requires PHP 8.1 or newer. It uses the official `apache/avro` package
+for portable payloads and accepts any PSR-18 HTTP client.
-To install directly from the source repository before a tagged release:
+## Choose Your Path
-```bash
-composer config repositories.durable-workflow-sdk vcs https://github.com/durable-workflow/sdk-php
-composer require durable-workflow/sdk:dev-main
-```
-
-The SDK uses the official [`apache/avro`](https://packagist.org/packages/apache/avro)
-package for schema parsing and binary payload encoding. Guzzle is included as
-the default PSR-18 transport; any PSR-18 client and PSR-17 factories can be
-injected instead.
-
-### Choose Cloud or Server without rewriting the URL
-
-Set one runtime URI exactly as provisioned:
-
-```bash
-# Self-hosted Server: pass the bare origin. The SDK appends one /api segment.
-export DURABLE_WORKFLOW_RUNTIME_URL='http://localhost:8080'
-export DURABLE_WORKFLOW_NAMESPACE='default'
-
-# Durable Workflow Cloud: instead use both values returned by provisioning.
-# export DURABLE_WORKFLOW_RUNTIME_URL='https://cloud.example/api/runtime/v1/namespaces/'
-# export DURABLE_WORKFLOW_NAMESPACE=''
-
-export DURABLE_WORKFLOW_TASK_QUEUE="php-quickstart-$(php -r 'echo bin2hex(random_bytes(8));')"
-```
-
-The Cloud URL already contains `/api/runtime/v1/namespaces/...`; do not trim
-that prefix or replace it with the Cloud control-plane URL. Keep the separately
-provisioned Cloud namespace value unchanged as well. The SDK appends its
-endpoint `/api` after the namespace runtime URI. For Server, pass an origin such
-as `http://localhost:8080`, not `http://localhost:8080/api`, so the request path
-contains one `/api` segment rather than two.
-
-Inject credentials through the process environment or a secret manager. Client
-operations and worker polling are separate roles, so keep their variables
-separate even when a development Server is configured with one shared token:
-
-```bash
-read -rsp 'Client credential: ' DURABLE_WORKFLOW_CLIENT_TOKEN; echo
-export DURABLE_WORKFLOW_CLIENT_TOKEN
-read -rsp 'Worker credential: ' DURABLE_WORKFLOW_WORKER_TOKEN; echo
-export DURABLE_WORKFLOW_WORKER_TOKEN
-```
-
-The prompts do not echo values. Do not put these exports in source files,
-commit an `.env` file, or print either value in diagnostics.
-
-### Create the three example files
-
-`bootstrap.php` resolves Composer consistently when the example is in a clean
-project, this SDK checkout, an installed SDK package, or a Sample App
-playground/container that copies the files beside its own `vendor/` directory.
-
-
-```php
-
```php
activity('quickstart.php.greet', [$name]);
+ $greeting = $context->activity('example.greet', [$name]);
return ['greeting' => $greeting];
}
@@ -163,539 +63,83 @@ final class GreeterWorkflow
final class GreetingActivities
{
- #[Activity('quickstart.php.greet')]
+ #[Activity('example.greet')]
public function greet(ActivityContext $context, string $name): string
{
- return "hello, {$name}";
+ return "Hello, {$name}";
}
}
-
-$client = new Client(
- quickstartEnvironment('DURABLE_WORKFLOW_RUNTIME_URL'),
- namespace: quickstartEnvironment('DURABLE_WORKFLOW_NAMESPACE'),
- workerToken: quickstartEnvironment('DURABLE_WORKFLOW_WORKER_TOKEN'),
-);
-
-Worker::create($client, quickstartEnvironment('DURABLE_WORKFLOW_TASK_QUEUE'))
- ->register(GreeterWorkflow::class, GreetingActivities::class)
- ->run();
```
-`client.php` starts a unique workflow and waits for its completed result.
+Register the classes on a task queue and start polling:
-
```php
-startWorkflow(
- workflowType: 'quickstart.php.greeter',
- workflowId: $workflowId,
- taskQueue: quickstartEnvironment('DURABLE_WORKFLOW_TASK_QUEUE'),
- input: ['PHP'],
-);
-
-$result = $handle->result(timeoutSeconds: 90, pollIntervalSeconds: 1);
-
-echo json_encode(
- ['workflow_id' => $workflowId, 'result' => $result],
- JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES,
-).PHP_EOL;
-```
-
-The files above ship under [`examples/`](examples/). Copy all three into the
-new project if you prefer not to create them from the visible blocks.
-
-### Start the worker, workflow, and result read
-
-Run the worker in the first terminal with only its role credential:
-
-```bash
-env -u DURABLE_WORKFLOW_CLIENT_TOKEN php worker.php
-```
-
-In a second terminal with the same runtime, namespace, and task queue, start the
-workflow and read the result with only the client credential:
-
-```bash
-env -u DURABLE_WORKFLOW_WORKER_TOKEN php client.php
-```
-
-The output contains a new workflow ID and this result shape:
-
-```json
-{"workflow_id":"php-quickstart-…","result":{"greeting":"hello, PHP"}}
-```
-
-Stop the worker with `Ctrl+C` after the client completes.
-
-`register()` is the preferred class-oriented API: it discovers every attributed
-handler in the supplied classes before polling. For generated handlers or other
-callable-first code, the direct alternative is explicit and does not use
-attributes:
-
-```php
-$worker = Worker::create($client, $taskQueue)
- ->registerWorkflow('quickstart.php.greeter', $workflowHandler)
- ->registerActivity('quickstart.php.greet', $activityHandler);
-```
-
-Both callables receive the same `WorkflowContext` and `ActivityContext` values
-shown in the class-oriented example. Do not call `register()` with un-attributed
-classes; use one complete registration style or the other.
-
-Release metadata and the machine-readable
-[quickstart contract](docs/quickstart-contract.json) together name the exact
-package and Server compatibility line, runtime URL forms, role-specific
-environment variables, package-resolvable executable sources, expected result,
-and public published-artifact qualification identity that keep this path in sync.
-For the stable 2.0 release, `product-train` names the exact SDK artifact and
-`supported-server-versions` names the exact standalone Server artifact
-qualified with it. Earlier prereleases remain historical and do not activate
-compatibility shims.
-
-`npm run qualify:quickstart-contract-deployment` verifies that deployed contract
-and its public workflow evidence. Local runs may omit `GITHUB_TOKEN` and use
-anonymous GitHub API access, limited to 30 seconds and five redirects, but fail
-explicitly if the workflow cannot be verified or the anonymous quota is
-exhausted. Supplying a token raises that quota; the qualifier sends it only to
-the exact `https://api.github.com` origin while requesting workflow metadata,
-never to the portal, schema, web evidence, redirect destinations, or the public
-contract itself.
-
-## Workflow handles and control-plane APIs
-
-`WorkflowHandle` distinguishes the stable workflow instance from a selected
-run. Its ordinary operations follow whichever run is current after a
-continue-as-new transition. The `*SelectedRun()` methods retain the original
-run guard and fail rather than silently targeting a successor.
-
-## Control-plane administration and discovery
-
-`Client::withNamespace()` returns an immutable namespace selection that keeps
-the configured authentication and transport. Workflow payload encoding remains
-Apache Avro. Workflow
-visibility results and the newly covered administrative surfaces use SDK model
-types while retaining the complete server payload in each model's `raw`
-property.
-
-```php
-use DurableWorkflow\Model\ServiceOperationOptions;
-
-$orders = $client->withNamespace('orders-prod');
-
-$page = $orders->listWorkflows(
- workflowType: 'orders.process',
- status: 'running',
- query: 'CustomerId = "42"',
- pageSize: 25,
-);
-$schedulePage = $orders->listSchedules(
- status: 'paused',
- workflowType: 'reports.rollup',
- query: 'Region = "eu-west"',
- pageSize: 25,
-);
-
-$attributes = $orders->listSearchAttributes();
-$orders->createSearchAttribute('OrderTotal', 'double');
-$orders->deleteSearchAttribute('TemporaryField');
-
-$orders->setNamespaceExternalStorage(
- 'orders-prod',
- 's3',
- thresholdBytes: 2 * 1024 * 1024,
- config: ['bucket' => 'workflow-payloads'],
+ getenv('DURABLE_WORKFLOW_RUNTIME_URL'),
+ namespace: getenv('DURABLE_WORKFLOW_NAMESPACE'),
+ workerToken: getenv('DURABLE_WORKFLOW_WORKER_TOKEN'),
);
-$operation = $orders->startServiceOperation(
- 'payments',
- 'Cards',
- 'authorize',
- ['amount' => 4200, 'currency' => 'USD'],
- new ServiceOperationOptions(idempotencyKey: 'order-42-authorization'),
-);
-
-$call = $operation->describe();
-$operation->cancel('customer request');
-$cluster = $orders->clusterInfo();
-```
-
-`listSchedules()` returns a typed page with `schedules`, `nextPageToken`, and
-the original response in `raw`. It supports server-side `status`, workflow
-type, visibility-query, page-size, and continuation-token filtering. Pass a
-non-null `nextPageToken` back unchanged with the same namespace and filters to
-read the next page. See the protocol guide for the paging and error contract.
-
-`startServiceOperation()` explicitly starts an asynchronous call and returns a
-`ServiceOperationHandle`. `executeServiceOperation()` honors the catalog mode,
-waits for completion by default, and returns a `ServiceOperationDescription`.
-Arguments use the official Apache Avro payload codec.
-
-## Run a remote PHP worker
-
-The preferred service-mode API discovers handler contracts from ordinary PHP
-classes. Attributes name the server contract while method signatures describe
-its arguments. Calls on `WorkflowContext` suspend the workflow's Fiber at each
-durable step. Replay resumes that call with its recorded value, or throws its
-recorded failure there, without repeating external work.
-
-```php
-activity('greet', [$name]);
-
- return ['greeting' => $greeting];
- }
-
- #[Query]
- public function status(QueryContext $context): array
- {
- return ['events' => count($context->history)];
- }
-
- #[Signal('set-language')]
- public function setLanguage(string $language): void
- {
- // This declaration is reflected for admission; run() consumes signals.
- }
-
- #[Update]
- public function rename(QueryContext $context, string $name): string
- {
- return $name;
- }
-}
-
-final class GreetingActivities
-{
- #[Activity]
- public function greet(ActivityContext $context, string $name): string
- {
- return "hello, {$name}";
- }
-}
-
-$client = new Client('http://server:8080', token: 'dev-token-123');
-
-Worker::create($client, 'php-workers')
- ->register(GreeterWorkflow::class, GreetingActivities::class)
+Worker::create($client, 'greetings')
+ ->register(GreetingWorkflow::class, GreetingActivities::class)
->run();
```
-`register()` resolves class names once and validates every attributed method
-before registration or polling. With no container, concrete classes with no
-required constructor arguments are instantiated automatically. Pass any PSR-11
-`ContainerInterface` as the third argument to `Worker::create()` when handlers
-have application dependencies.
-
-Attributed workflow classes have a replay-scoped lifecycle. Registration
-captures a clean handler template, then each workflow task replay, query, and
-update runs on a fresh shallow clone. Mutable properties on the workflow object
-therefore cannot cross workflow IDs, runs, or replay attempts, while
-constructor-injected collaborators retain their configured identity. Keep
-workflow-local mutable state directly on the handler; injected collaborators
-are shared services and must not be used to hold execution-local state.
-Workflow handler classes must remain cloneable.
-
-Activity services have worker-scoped lifetimes instead: their resolved instance
-is reused for activity tasks, so they can retain clients and other service
-resources. The explicit `registerWorkflow()`, `registerQuery()`, and
-`registerUpdate()` low-level methods also invoke the supplied callable as-is;
-state captured by such a callable remains owned by the application. Use
-attribute-based workflow registration when the SDK should provide replay-state
-isolation.
-
-Pass a PSR-3 `LoggerInterface` with the named `logger` argument; lifecycle,
-retry, shutdown, and handler failures then use the application's normal logging
-pipeline. The optional `diagnosticListener` receives the same event names and
-structured context.
-
-Signal methods are signature declarations for server admission and are not
-invoked. The workflow reads their committed values with
-`$context->signals('set-language')` during replay. Query and update methods are
-executed with immutable `QueryContext` state.
-
-The PHP SDK does not expose update-validator authoring. Worker registration
-therefore declares an empty `update_validators` list for every workflow type;
-capability discovery and Server admission must not infer validator parity from
-the presence of ordinary update handlers.
-
-The callable registration methods remain the intentional low-level escape
-hatch. For example, replay-consumed signals can be declared directly:
+Start the workflow from a client process using a client-role credential:
```php
-$worker->declareSignal(
- 'counter',
- 'increment',
- static fn (int $amount): mixed => null,
+$client = new Client(
+ getenv('DURABLE_WORKFLOW_RUNTIME_URL'),
+ namespace: getenv('DURABLE_WORKFLOW_NAMESPACE'),
+ controlToken: getenv('DURABLE_WORKFLOW_CLIENT_TOKEN'),
);
-```
-
-Call `$context->heartbeat($details)` from a long-running activity. It throws
-`ActivityCancelled` when the server requests cancellation. `Worker::run()`
-installs SIGINT/SIGTERM handlers when `pcntl` is available, stops accepting new
-tasks, and lets the active synchronous task settle before returning. The
-managed worker also returns when any task poll reports a terminal typed outcome
-such as `stale_worker_registration`, `draining`, or `stopped`; empty and timeout
-polls remain idle. Registration also negotiates the worker heartbeat cadence.
-Managed long polls are bounded by that cadence and heartbeat checks run between
-workflow, activity, and query polls, so an idle polling cycle cannot silently
-consume the server's registration freshness window. Invalid advertised cadence
-values leave the worker's configured safe fallback in effect.
-
-Low-level worker integrations can call `pollWorkflowTaskResponse()`,
-`pollActivityTaskResponse()`, and `pollQueryTaskResponse()` to receive the
-complete server envelope, including `poll_status`, `reason`, protocol metadata,
-and any future fields. The existing task-only poll methods delegate to these
-response methods and still return the leased task or `null`. Use
-`DurableWorkflow\Worker\PollResponse::isTerminal()` to apply the same typed
-terminal-outcome classification as the managed worker.
-
-When a poll fails with a complete worker-protocol envelope explicitly marked as
-transient, the managed worker retries that same poll with capped backoff while
-keeping heartbeats and graceful shutdown responsive. Pass a
-`transientPollRetryObserver` callback to the `Worker` constructor to record the
-task kind, consecutive attempt, selected delay, and typed server exception.
-Authentication failures, malformed responses, and generic server errors remain
-fatal.
-
-## Receive repeated input with Message Streams
-
-Inbound Message Streams deliver repeated, ordered application input to a stable
-workflow instance. The application appends a stable message identity through
-`WorkflowHandle::appendMessage()`, while workflow code consumes one message or
-a bounded ordered batch through `messageStream()`. Runtime-owned cursors survive
-replay, worker replacement, server restart, and continue-as-new.
-
-See the task-oriented [Message Streams guide](https://php.durable-workflow.com/build/message-streams/)
-and the shipped [client](examples/message-stream-client.php) and
-[worker](examples/message-stream-worker.php) examples.
-
-## Laravel service mode
-
-Laravel 9 through 13 auto-discover the service provider from the same SDK package.
-Publish the environment-backed configuration, add attributed handler services,
-and start the supervised Artisan command:
-
-```bash
-composer require 'durable-workflow/sdk:^2.0'
-php artisan vendor:publish --tag=durable-workflow-config
-php artisan config:cache
-php artisan durable-workflow:worker
-```
-
-Set `DURABLE_WORKFLOW_RUNTIME_URL` to a self-hosted Server origin or the complete
-Cloud runtime base URI. Set `DURABLE_WORKFLOW_NAMESPACE` and
-`DURABLE_WORKFLOW_TASK_QUEUE`, then choose shared-token or scoped authentication.
-For scoped Cloud authentication, inject credentials at the process boundary:
-
-| Laravel process | Inject | Do not inject |
-| --- | --- | --- |
-| Web, queue, or other application process | `DURABLE_WORKFLOW_CLIENT_TOKEN` | `DURABLE_WORKFLOW_WORKER_TOKEN` |
-| `php artisan durable-workflow:worker` | `DURABLE_WORKFLOW_WORKER_TOKEN` | `DURABLE_WORKFLOW_CLIENT_TOKEN` |
-
-The service provider gives the injectable application interfaces only the client
-credential and creates a separate worker client only for the worker factory.
-For a self-hosted deployment that uses one credential for both roles, inject
-`DURABLE_WORKFLOW_TOKEN` instead. Supply secret values through the deployment
-platform's process environment or secret store, not a generated configuration
-file or a committed `.env` file.
-
-The published configuration deliberately has no credential entries. Build and
-deploy one cached configuration without any Durable Workflow credential in the
-cache-building environment, then inject only the required role credential when
-each application or worker process starts. Resolving either documented client
-interface is credential-lazy; the provider resolves the client credential when
-the interface performs its first application-client operation, after Laravel has
-loaded the cache. Resolving `Client` directly is the explicit eager low-level
-path and validates the application credential immediately.
-Applications upgrading an older published configuration should republish it or
-remove its `credentials` block before rebuilding the cache. List handler classes
-in `config/durable-workflow.php`:
-```php
-'handlers' => [
- App\Workflows\GreeterWorkflow::class,
- App\Activities\GreetingActivities::class,
-],
-```
-
-Laravel resolves every handler through its container, so ordinary constructor
-injection works. Inject `LaravelWorkflowClientInterface` to start an attributed
-workflow service class on the configured default queue; explicit IDs and
-`WorkflowStartOptions` remain available. Inject `WorkflowClientInterface` for
-low-level cross-language string contracts; like the Laravel-shaped interface,
-it is safe to constructor-inject into services that may be discovered by a
-worker-only Artisan process. Inject `Client` directly only when eager application
-credential validation and its broader concrete API are intentional.
-Worker diagnostics use Laravel's PSR logger and dispatch
-`WorkerDiagnosticEvent` through Laravel events. The event name is available in
-its `name` property and includes lifecycle, retry, handler-failure, and shutdown
-events. After server registration, Artisan prints a registered-and-polling line
-with the runtime host, namespace, queue, workflow and activity types, and
-credential role; it never includes credential values.
-
-In a Laravel test, `DurableWorkflow::fake()` replaces the class-shaped Laravel
-client and its low-level transport. It returns `LaravelWorkflowClientFake` for
-result setup and service-class interaction assertions:
-
-```php
-$workflows = DurableWorkflow::fake()
- ->setWorkflowResult('greeting-1', ['greeting' => 'hello, Ada']);
-
-// Exercise application code, then use the framework-independent assertions.
-$workflows->assertWorkflowStarted(GreeterWorkflow::class, ['Ada']);
-```
-
-## Symfony service mode
-
-Symfony 6.4, 7, and 8 applications register the Bundle from the SDK package in
-`config/bundles.php`:
+$handle = $client->startWorkflow(
+ workflowType: 'example.greeting',
+ workflowId: 'greeting-'.bin2hex(random_bytes(12)),
+ taskQueue: 'greetings',
+ input: ['PHP'],
+);
-```php
-return [
- // ...
- DurableWorkflow\Bridge\Symfony\DurableWorkflowBundle::class => ['all' => true],
-];
+var_dump($handle->result());
```
-Configure Server or Cloud through environment processors. Attributed services
-under Symfony's normal autoconfigured imports are registered as handlers. The
-optional `handlers` list also registers classes outside those imports as
-autowired services:
-
-```yaml
-# config/packages/durable_workflow.yaml
-durable_workflow:
- endpoint: '%env(DURABLE_WORKFLOW_RUNTIME_URL)%'
- namespace: '%env(DURABLE_WORKFLOW_NAMESPACE)%'
- task_queue: '%env(DURABLE_WORKFLOW_TASK_QUEUE)%'
- credentials:
- control_token: '%env(default::DURABLE_WORKFLOW_CLIENT_TOKEN)%'
- worker_token: '%env(default::DURABLE_WORKFLOW_WORKER_TOKEN)%'
- handlers:
- - App\Workflow\GreeterWorkflow
- - App\Activity\GreetingActivities
-```
+For Cloud, use the complete namespace runtime URL exactly as provisioned. For
+self-hosted Server, use its origin such as `http://localhost:8080`. Keep client
+and worker credentials in separate processes.
-Inject `DURABLE_WORKFLOW_CLIENT_TOKEN` only into web and other application
-processes. Inject `DURABLE_WORKFLOW_WORKER_TOKEN` only into the process running
-`php bin/console durable-workflow:worker`; the `default::` processors leave the
-opposite scoped credential unset. The Bundle binds the public autowired client
-to the client credential and gives its private worker client only the worker
-credential. Self-hosted deployments can instead set `credentials.token` from
-`DURABLE_WORKFLOW_TOKEN` and inject that shared credential into both
-processes. Keep values in the deployment platform's environment or secret store,
-not YAML, generated container files, or committed environment files.
-
-Run `php bin/console durable-workflow:worker`. `Client` and
-`WorkflowClientInterface` are public autowired services. Handler services retain
-normal Symfony autowiring, worker messages use the standard PSR logger when it
-is installed, and `WorkerDiagnosticEvent` is dispatched through Symfony's event
-dispatcher under the diagnostic name. A `KernelTestCase` can use
-`InteractsWithDurableWorkflow::fakeDurableWorkflow()` to replace the autowired
-interface with the same assertion-capable fake used by plain PHP and Laravel.
-
-Both console commands accept `--queue` and `--poll-timeout`. They require
-`ext-pcntl` so SIGINT and SIGTERM always request a graceful worker shutdown.
-Configuration errors, an unreachable endpoint, rejected credentials, and
-worker-protocol or contract mismatches are reported with remediation specific
-to the failing boundary. Neither bridge stores workflow state or installs the
-embedded Laravel workflow engine.
-
-## Test workflow code and interactions
-
-The testing namespace has no PHPUnit dependency. Its assertions throw
-`DurableWorkflow\Testing\AssertionFailed`, so they work with PHPUnit, Pest, or
-plain PHP. Application services can type their dependency as
-`WorkflowClientInterface`; both the network `Client` and `WorkflowClientFake`
-implement that interface and return handles with the same interaction methods.
+The complete [plain PHP quickstart](https://php.durable-workflow.com/getting-started/first-workflow/)
+includes the three runnable files, environment setup, expected output, and
+common failure diagnostics.
-```php
-use DurableWorkflow\Testing\WorkerTestHarness;
-use DurableWorkflow\Testing\WorkflowClientFake;
-
-$worker = Worker::create($client, 'php-workers')
- ->register(GreeterWorkflow::class, GreetingActivities::class);
-$handlers = new WorkerTestHarness($worker);
-$handlers->assertWorkflowEmits('greeter', 'schedule_activity', ['Ada']);
-$handlers->assertActivityResult('greet', 'hello, Ada', ['Ada']);
-$handlers->assertQueryResult('greeter', 'status', ['events' => 0]);
-$handlers->assertUpdateResult('greeter', 'rename', 'Grace', ['Grace']);
-$handlers->assertRegistered('signal', 'set-language', 'greeter');
-
-$workflows = (new WorkflowClientFake())
- ->setQueryResult('greeting-1', 'status', 'running')
- ->setUpdateResult('greeting-1', 'rename', 'accepted')
- ->setWorkflowResult('greeting-1', ['greeting' => 'hello, Ada']);
-$handle = $workflows->startWorkflow('greeter', 'greeting-1', 'php-workers', ['Ada']);
-$handle->signal('set-language', ['en']);
-$handle->query('status');
-$handle->update('rename', ['Grace']);
-$handle->result();
-
-$workflows->assertWorkflowStarted('greeter', ['Ada']);
-$workflows->assertSignalSent('greeting-1', 'set-language', ['en']);
-$workflows->assertQueryRequested('greeting-1', 'status');
-$workflows->assertUpdateRequested('greeting-1', 'rename', ['Grace']);
-$workflows->assertResultRequested('greeting-1');
-```
+## Capabilities
-Workflow tasks additionally require an acknowledged lease renewal before user
-code runs. Typed transient renewal pressure is retried with the original task
-ID, attempt, and lease owner; shutdown or a terminal/lost lease prevents task
-execution and completion.
+- Workflows, activities, child workflows, timers, retries, and heartbeats
+- Signals, queries, updates, condition waits, and message streams
+- Parallel work, sagas, cancellation, continue-as-new, and version markers
+- Schedules, search attributes, memo, external payloads, and worker versioning
+- Replay testing, in-memory client fakes, Laravel and Symfony test helpers
+- Stable workflow handles and machine-readable runtime diagnostics
-See [`examples/`](examples), the authored
-[PHP developer portal](https://php.durable-workflow.com/), the generated
-[API reference](https://php.durable-workflow.com/api/), and
-[`docs/protocol.md`](docs/protocol.md) for the complete client, schedule,
-namespace, visibility, search-attribute, service-operation, discovery,
-authentication, worker, query, and update surfaces.
+See the [complete SDK reference](docs/sdk-reference.md) for control-plane APIs,
+worker configuration, Message Streams, framework setup, and testing examples.
+The generated [API reference](https://php.durable-workflow.com/api/) documents
+every public class and method.
## Development
```bash
composer install
-composer validate --strict
composer test
composer analyse
-composer docs
+composer benchmark-avro-value
```
-The dependency-boundary check rejects Laravel, Illuminate, the embedded
-workflow package, and the standalone server package in both declared and
-resolved production dependencies.
+See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution and validation details.
+
+## License
+
+MIT
diff --git a/composer.json b/composer.json
index 4140dca..99bdd07 100644
--- a/composer.json
+++ b/composer.json
@@ -1,10 +1,10 @@
{
"name": "durable-workflow/sdk",
- "description": "Framework-neutral PHP client and worker SDK for the Durable Workflow server",
+ "description": "PHP client and worker SDK for Durable Workflow Cloud and self-hosted Server",
"type": "library",
"license": "MIT",
- "keywords": ["durable", "workflow", "orchestration", "worker", "sdk"],
- "homepage": "https://durable-workflow.com",
+ "keywords": ["cloud", "durable", "laravel", "orchestration", "php", "sdk", "symfony", "worker", "workflow"],
+ "homepage": "https://php.durable-workflow.com/",
"support": {
"docs": "https://php.durable-workflow.com/",
"issues": "https://github.com/durable-workflow/sdk-php/issues",
diff --git a/docs/portal/frameworks/laravel.md b/docs/portal/frameworks/laravel.md
index c6571fb..13bf6d3 100644
--- a/docs/portal/frameworks/laravel.md
+++ b/docs/portal/frameworks/laravel.md
@@ -51,10 +51,9 @@ when it intentionally overrides the Laravel defaults.
The public [Laravel adoption contract](/laravel-adoption-contract.json) is the
machine-readable authority for this matrix, supported Laravel/PHP cells,
-continuity surfaces, and qualification destinations. It uses release channels,
-not copied prerelease sequence numbers. Its embedded transition row references
-the transition manifest shipped by `durable-workflow/workflow`; the service SDK
-does not publish a second embedded support policy.
+continuity surfaces, and qualification destinations. Its embedded transition
+row references the transition manifest shipped by `durable-workflow/workflow`,
+which owns the embedded support policy.
## Plan continuity before changing packages
diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md
new file mode 100644
index 0000000..6fed831
--- /dev/null
+++ b/docs/sdk-reference.md
@@ -0,0 +1,685 @@
+# PHP SDK Reference
+
+This is the detailed repository reference for the first-party PHP client and
+worker SDK. Start with the [root README](../README.md) or the
+[PHP developer portal](https://php.durable-workflow.com/) for the shortest
+runnable path.
+
+## Choose the PHP execution model
+
+- **Laravel adoption:** start with the [ownership-first transition
+ guide](https://php.durable-workflow.com/frameworks/laravel/) when moving a
+ Laravel Workflow v1 application to v2 embedded or service mode, or when
+ moving embedded v2 to service mode. Laravel 9 through 13 are supported on
+ both v2 destinations.
+- **Plain PHP service mode:** follow the quickstart below when a framework-neutral
+ application and remote worker connect to Durable Workflow Cloud or a
+ self-hosted Server.
+- **Symfony service mode:** use the [Symfony bridge](#symfony-service-mode) from
+ this SDK for autowired remote handlers and a managed console worker.
+- **Embedded Laravel workflows:** use
+ [`durable-workflow/workflow`](https://php.durable-workflow.com/frameworks/laravel/)
+ when the Laravel application itself should own durable state and execute
+ through Laravel queues. That is a different deployment model, not a
+ prerequisite for this SDK.
+
+## Plain PHP quickstart
+
+Create an empty Composer project and install the current published package:
+
+```bash
+mkdir durable-php-quickstart
+cd durable-php-quickstart
+composer init --name=acme/durable-php-quickstart --no-interaction
+composer require 'durable-workflow/sdk:^2.0'
+```
+
+The package declares its supported Server baseline in Composer metadata.
+
+Contributors testing the current source branch can install it directly:
+
+```bash
+composer config repositories.durable-workflow-sdk vcs https://github.com/durable-workflow/sdk-php
+composer require durable-workflow/sdk:dev-main
+```
+
+The SDK uses the official [`apache/avro`](https://packagist.org/packages/apache/avro)
+package for schema parsing and binary payload encoding. Guzzle is included as
+the default PSR-18 transport; any PSR-18 client and PSR-17 factories can be
+injected instead.
+
+### Choose Cloud or Server without rewriting the URL
+
+Set one runtime URI exactly as provisioned:
+
+```bash
+# Self-hosted Server: pass the bare origin. The SDK appends one /api segment.
+export DURABLE_WORKFLOW_RUNTIME_URL='http://localhost:8080'
+export DURABLE_WORKFLOW_NAMESPACE='default'
+
+# Durable Workflow Cloud: instead use both values returned by provisioning.
+# export DURABLE_WORKFLOW_RUNTIME_URL='https://cloud.example/api/runtime/v1/namespaces/'
+# export DURABLE_WORKFLOW_NAMESPACE=''
+
+export DURABLE_WORKFLOW_TASK_QUEUE="php-quickstart-$(php -r 'echo bin2hex(random_bytes(8));')"
+```
+
+The Cloud URL already contains `/api/runtime/v1/namespaces/...`; do not trim
+that prefix or replace it with the Cloud control-plane URL. Keep the separately
+provisioned Cloud namespace value unchanged as well. The SDK appends its
+endpoint `/api` after the namespace runtime URI. For Server, pass an origin such
+as `http://localhost:8080`, not `http://localhost:8080/api`, so the request path
+contains one `/api` segment rather than two.
+
+Inject credentials through the process environment or a secret manager. Client
+operations and worker polling are separate roles, so keep their variables
+separate even when a development Server is configured with one shared token:
+
+```bash
+read -rsp 'Client credential: ' DURABLE_WORKFLOW_CLIENT_TOKEN; echo
+export DURABLE_WORKFLOW_CLIENT_TOKEN
+read -rsp 'Worker credential: ' DURABLE_WORKFLOW_WORKER_TOKEN; echo
+export DURABLE_WORKFLOW_WORKER_TOKEN
+```
+
+The prompts do not echo values. Do not put these exports in source files,
+commit an `.env` file, or print either value in diagnostics.
+
+### Create the three example files
+
+`bootstrap.php` resolves Composer consistently when the example is in a clean
+project, this SDK checkout, an installed SDK package, or a Sample App
+playground/container that copies the files beside its own `vendor/` directory.
+
+
+```php
+
+```php
+activity('quickstart.php.greet', [$name]);
+
+ return ['greeting' => $greeting];
+ }
+}
+
+final class GreetingActivities
+{
+ #[Activity('quickstart.php.greet')]
+ public function greet(ActivityContext $context, string $name): string
+ {
+ return "hello, {$name}";
+ }
+}
+
+$client = new Client(
+ quickstartEnvironment('DURABLE_WORKFLOW_RUNTIME_URL'),
+ namespace: quickstartEnvironment('DURABLE_WORKFLOW_NAMESPACE'),
+ workerToken: quickstartEnvironment('DURABLE_WORKFLOW_WORKER_TOKEN'),
+);
+
+Worker::create($client, quickstartEnvironment('DURABLE_WORKFLOW_TASK_QUEUE'))
+ ->register(GreeterWorkflow::class, GreetingActivities::class)
+ ->run();
+```
+
+`client.php` starts a unique workflow and waits for its completed result.
+
+
+```php
+startWorkflow(
+ workflowType: 'quickstart.php.greeter',
+ workflowId: $workflowId,
+ taskQueue: quickstartEnvironment('DURABLE_WORKFLOW_TASK_QUEUE'),
+ input: ['PHP'],
+);
+
+$result = $handle->result(timeoutSeconds: 90, pollIntervalSeconds: 1);
+
+echo json_encode(
+ ['workflow_id' => $workflowId, 'result' => $result],
+ JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES,
+).PHP_EOL;
+```
+
+The files above ship under [`examples/`](../examples/). Copy all three into the
+new project if you prefer not to create them from the visible blocks.
+
+### Start the worker, workflow, and result read
+
+Run the worker in the first terminal with only its role credential:
+
+```bash
+env -u DURABLE_WORKFLOW_CLIENT_TOKEN php worker.php
+```
+
+In a second terminal with the same runtime, namespace, and task queue, start the
+workflow and read the result with only the client credential:
+
+```bash
+env -u DURABLE_WORKFLOW_WORKER_TOKEN php client.php
+```
+
+The output contains a new workflow ID and this result shape:
+
+```json
+{"workflow_id":"php-quickstart-…","result":{"greeting":"hello, PHP"}}
+```
+
+Stop the worker with `Ctrl+C` after the client completes.
+
+`register()` is the preferred class-oriented API: it discovers every attributed
+handler in the supplied classes before polling. For generated handlers or other
+callable-first code, the direct alternative is explicit and does not use
+attributes:
+
+```php
+$worker = Worker::create($client, $taskQueue)
+ ->registerWorkflow('quickstart.php.greeter', $workflowHandler)
+ ->registerActivity('quickstart.php.greet', $activityHandler);
+```
+
+Both callables receive the same `WorkflowContext` and `ActivityContext` values
+shown in the class-oriented example. Do not call `register()` with un-attributed
+classes; use one complete registration style or the other.
+
+The machine-readable [quickstart contract](quickstart-contract.json) records
+the supported runtime URL forms, role-specific environment variables,
+package-owned source files, and expected result. Documentation builds verify
+that the visible examples still match those runnable files.
+
+## Workflow handles and control-plane APIs
+
+`WorkflowHandle` distinguishes the stable workflow instance from a selected
+run. Its ordinary operations follow whichever run is current after a
+continue-as-new transition. The `*SelectedRun()` methods retain the original
+run guard and fail rather than silently targeting a successor.
+
+## Control-plane administration and discovery
+
+`Client::withNamespace()` returns an immutable namespace selection that keeps
+the configured authentication and transport. Workflow payload encoding remains
+Apache Avro. Workflow
+visibility results and the newly covered administrative surfaces use SDK model
+types while retaining the complete server payload in each model's `raw`
+property.
+
+```php
+use DurableWorkflow\Model\ServiceOperationOptions;
+
+$orders = $client->withNamespace('orders-prod');
+
+$page = $orders->listWorkflows(
+ workflowType: 'orders.process',
+ status: 'running',
+ query: 'CustomerId = "42"',
+ pageSize: 25,
+);
+$schedulePage = $orders->listSchedules(
+ status: 'paused',
+ workflowType: 'reports.rollup',
+ query: 'Region = "eu-west"',
+ pageSize: 25,
+);
+
+$attributes = $orders->listSearchAttributes();
+$orders->createSearchAttribute('OrderTotal', 'double');
+$orders->deleteSearchAttribute('TemporaryField');
+
+$orders->setNamespaceExternalStorage(
+ 'orders-prod',
+ 's3',
+ thresholdBytes: 2 * 1024 * 1024,
+ config: ['bucket' => 'workflow-payloads'],
+);
+
+$operation = $orders->startServiceOperation(
+ 'payments',
+ 'Cards',
+ 'authorize',
+ ['amount' => 4200, 'currency' => 'USD'],
+ new ServiceOperationOptions(idempotencyKey: 'order-42-authorization'),
+);
+
+$call = $operation->describe();
+$operation->cancel('customer request');
+$cluster = $orders->clusterInfo();
+```
+
+`listSchedules()` returns a typed page with `schedules`, `nextPageToken`, and
+the original response in `raw`. It supports server-side `status`, workflow
+type, visibility-query, page-size, and continuation-token filtering. Pass a
+non-null `nextPageToken` back unchanged with the same namespace and filters to
+read the next page. See the protocol guide for the paging and error contract.
+
+`startServiceOperation()` explicitly starts an asynchronous call and returns a
+`ServiceOperationHandle`. `executeServiceOperation()` honors the catalog mode,
+waits for completion by default, and returns a `ServiceOperationDescription`.
+Arguments use the official Apache Avro payload codec.
+
+## Run a remote PHP worker
+
+The preferred service-mode API discovers handler contracts from ordinary PHP
+classes. Attributes name the server contract while method signatures describe
+its arguments. Calls on `WorkflowContext` suspend the workflow's Fiber at each
+durable step. Replay resumes that call with its recorded value, or throws its
+recorded failure there, without repeating external work.
+
+```php
+activity('greet', [$name]);
+
+ return ['greeting' => $greeting];
+ }
+
+ #[Query]
+ public function status(QueryContext $context): array
+ {
+ return ['events' => count($context->history)];
+ }
+
+ #[Signal('set-language')]
+ public function setLanguage(string $language): void
+ {
+ // This declaration is reflected for admission; run() consumes signals.
+ }
+
+ #[Update]
+ public function rename(QueryContext $context, string $name): string
+ {
+ return $name;
+ }
+}
+
+final class GreetingActivities
+{
+ #[Activity]
+ public function greet(ActivityContext $context, string $name): string
+ {
+ return "hello, {$name}";
+ }
+}
+
+$client = new Client('http://server:8080', token: 'dev-token-123');
+
+Worker::create($client, 'php-workers')
+ ->register(GreeterWorkflow::class, GreetingActivities::class)
+ ->run();
+```
+
+`register()` resolves class names once and validates every attributed method
+before registration or polling. With no container, concrete classes with no
+required constructor arguments are instantiated automatically. Pass any PSR-11
+`ContainerInterface` as the third argument to `Worker::create()` when handlers
+have application dependencies.
+
+Attributed workflow classes have a replay-scoped lifecycle. Registration
+captures a clean handler template, then each workflow task replay, query, and
+update runs on a fresh shallow clone. Mutable properties on the workflow object
+therefore cannot cross workflow IDs, runs, or replay attempts, while
+constructor-injected collaborators retain their configured identity. Keep
+workflow-local mutable state directly on the handler; injected collaborators
+are shared services and must not be used to hold execution-local state.
+Workflow handler classes must remain cloneable.
+
+Activity services have worker-scoped lifetimes instead: their resolved instance
+is reused for activity tasks, so they can retain clients and other service
+resources. The explicit `registerWorkflow()`, `registerQuery()`, and
+`registerUpdate()` low-level methods also invoke the supplied callable as-is;
+state captured by such a callable remains owned by the application. Use
+attribute-based workflow registration when the SDK should provide replay-state
+isolation.
+
+Pass a PSR-3 `LoggerInterface` with the named `logger` argument; lifecycle,
+retry, shutdown, and handler failures then use the application's normal logging
+pipeline. The optional `diagnosticListener` receives the same event names and
+structured context.
+
+Signal methods are signature declarations for server admission and are not
+invoked. The workflow reads their committed values with
+`$context->signals('set-language')` during replay. Query and update methods are
+executed with immutable `QueryContext` state.
+
+The PHP SDK does not expose update-validator authoring. Worker registration
+therefore declares an empty `update_validators` list for every workflow type;
+capability discovery and Server admission must not infer validator parity from
+the presence of ordinary update handlers.
+
+The callable registration methods remain the intentional low-level escape
+hatch. For example, replay-consumed signals can be declared directly:
+
+```php
+$worker->declareSignal(
+ 'counter',
+ 'increment',
+ static fn (int $amount): mixed => null,
+);
+```
+
+Call `$context->heartbeat($details)` from a long-running activity. It throws
+`ActivityCancelled` when the server requests cancellation. `Worker::run()`
+installs SIGINT/SIGTERM handlers when `pcntl` is available, stops accepting new
+tasks, and lets the active synchronous task settle before returning. The
+managed worker also returns when any task poll reports a terminal typed outcome
+such as `stale_worker_registration`, `draining`, or `stopped`; empty and timeout
+polls remain idle. Registration also negotiates the worker heartbeat cadence.
+Managed long polls are bounded by that cadence and heartbeat checks run between
+workflow, activity, and query polls, so an idle polling cycle cannot silently
+consume the server's registration freshness window. Invalid advertised cadence
+values leave the worker's configured safe fallback in effect.
+
+Low-level worker integrations can call `pollWorkflowTaskResponse()`,
+`pollActivityTaskResponse()`, and `pollQueryTaskResponse()` to receive the
+complete server envelope, including `poll_status`, `reason`, protocol metadata,
+and any future fields. The existing task-only poll methods delegate to these
+response methods and still return the leased task or `null`. Use
+`DurableWorkflow\Worker\PollResponse::isTerminal()` to apply the same typed
+terminal-outcome classification as the managed worker.
+
+When a poll fails with a complete worker-protocol envelope explicitly marked as
+transient, the managed worker retries that same poll with capped backoff while
+keeping heartbeats and graceful shutdown responsive. Pass a
+`transientPollRetryObserver` callback to the `Worker` constructor to record the
+task kind, consecutive attempt, selected delay, and typed server exception.
+Authentication failures, malformed responses, and generic server errors remain
+fatal.
+
+## Receive repeated input with Message Streams
+
+Inbound Message Streams deliver repeated, ordered application input to a stable
+workflow instance. The application appends a stable message identity through
+`WorkflowHandle::appendMessage()`, while workflow code consumes one message or
+a bounded ordered batch through `messageStream()`. Runtime-owned cursors survive
+replay, worker replacement, server restart, and continue-as-new.
+
+See the task-oriented [Message Streams guide](https://php.durable-workflow.com/build/message-streams/)
+and the shipped [client](../examples/message-stream-client.php) and
+[worker](../examples/message-stream-worker.php) examples.
+
+## Laravel service mode
+
+Laravel 9 through 13 auto-discover the service provider from the same SDK package.
+Publish the environment-backed configuration, add attributed handler services,
+and start the supervised Artisan command:
+
+```bash
+composer require 'durable-workflow/sdk:^2.0'
+php artisan vendor:publish --tag=durable-workflow-config
+php artisan config:cache
+php artisan durable-workflow:worker
+```
+
+Set `DURABLE_WORKFLOW_RUNTIME_URL` to a self-hosted Server origin or the complete
+Cloud runtime base URI. Set `DURABLE_WORKFLOW_NAMESPACE` and
+`DURABLE_WORKFLOW_TASK_QUEUE`, then choose shared-token or scoped authentication.
+For scoped Cloud authentication, inject credentials at the process boundary:
+
+| Laravel process | Inject | Do not inject |
+| --- | --- | --- |
+| Web, queue, or other application process | `DURABLE_WORKFLOW_CLIENT_TOKEN` | `DURABLE_WORKFLOW_WORKER_TOKEN` |
+| `php artisan durable-workflow:worker` | `DURABLE_WORKFLOW_WORKER_TOKEN` | `DURABLE_WORKFLOW_CLIENT_TOKEN` |
+
+The service provider gives the injectable application interfaces only the client
+credential and creates a separate worker client only for the worker factory.
+For a self-hosted deployment that uses one credential for both roles, inject
+`DURABLE_WORKFLOW_TOKEN` instead. Supply secret values through the deployment
+platform's process environment or secret store, not a generated configuration
+file or a committed `.env` file.
+
+The published configuration deliberately has no credential entries. Build and
+deploy one cached configuration without any Durable Workflow credential in the
+cache-building environment, then inject only the required role credential when
+each application or worker process starts. Resolving either documented client
+interface is credential-lazy; the provider resolves the client credential when
+the interface performs its first application-client operation, after Laravel has
+loaded the cache. Resolving `Client` directly is the explicit eager low-level
+path and validates the application credential immediately.
+Applications upgrading an older published configuration should republish it or
+remove its `credentials` block before rebuilding the cache. List handler classes
+in `config/durable-workflow.php`:
+
+```php
+'handlers' => [
+ App\Workflows\GreeterWorkflow::class,
+ App\Activities\GreetingActivities::class,
+],
+```
+
+Laravel resolves every handler through its container, so ordinary constructor
+injection works. Inject `LaravelWorkflowClientInterface` to start an attributed
+workflow service class on the configured default queue; explicit IDs and
+`WorkflowStartOptions` remain available. Inject `WorkflowClientInterface` for
+low-level cross-language string contracts; like the Laravel-shaped interface,
+it is safe to constructor-inject into services that may be discovered by a
+worker-only Artisan process. Inject `Client` directly only when eager application
+credential validation and its broader concrete API are intentional.
+Worker diagnostics use Laravel's PSR logger and dispatch
+`WorkerDiagnosticEvent` through Laravel events. The event name is available in
+its `name` property and includes lifecycle, retry, handler-failure, and shutdown
+events. After server registration, Artisan prints a registered-and-polling line
+with the runtime host, namespace, queue, workflow and activity types, and
+credential role; it never includes credential values.
+
+In a Laravel test, `DurableWorkflow::fake()` replaces the class-shaped Laravel
+client and its low-level transport. It returns `LaravelWorkflowClientFake` for
+result setup and service-class interaction assertions:
+
+```php
+$workflows = DurableWorkflow::fake()
+ ->setWorkflowResult('greeting-1', ['greeting' => 'hello, Ada']);
+
+// Exercise application code, then use the framework-independent assertions.
+$workflows->assertWorkflowStarted(GreeterWorkflow::class, ['Ada']);
+```
+
+## Symfony service mode
+
+Symfony 6.4, 7, and 8 applications register the Bundle from the SDK package in
+`config/bundles.php`:
+
+```php
+return [
+ // ...
+ DurableWorkflow\Bridge\Symfony\DurableWorkflowBundle::class => ['all' => true],
+];
+```
+
+Configure Server or Cloud through environment processors. Attributed services
+under Symfony's normal autoconfigured imports are registered as handlers. The
+optional `handlers` list also registers classes outside those imports as
+autowired services:
+
+```yaml
+# config/packages/durable_workflow.yaml
+durable_workflow:
+ endpoint: '%env(DURABLE_WORKFLOW_RUNTIME_URL)%'
+ namespace: '%env(DURABLE_WORKFLOW_NAMESPACE)%'
+ task_queue: '%env(DURABLE_WORKFLOW_TASK_QUEUE)%'
+ credentials:
+ control_token: '%env(default::DURABLE_WORKFLOW_CLIENT_TOKEN)%'
+ worker_token: '%env(default::DURABLE_WORKFLOW_WORKER_TOKEN)%'
+ handlers:
+ - App\Workflow\GreeterWorkflow
+ - App\Activity\GreetingActivities
+```
+
+Inject `DURABLE_WORKFLOW_CLIENT_TOKEN` only into web and other application
+processes. Inject `DURABLE_WORKFLOW_WORKER_TOKEN` only into the process running
+`php bin/console durable-workflow:worker`; the `default::` processors leave the
+opposite scoped credential unset. The Bundle binds the public autowired client
+to the client credential and gives its private worker client only the worker
+credential. Self-hosted deployments can instead set `credentials.token` from
+`DURABLE_WORKFLOW_TOKEN` and inject that shared credential into both
+processes. Keep values in the deployment platform's environment or secret store,
+not YAML, generated container files, or committed environment files.
+
+Run `php bin/console durable-workflow:worker`. `Client` and
+`WorkflowClientInterface` are public autowired services. Handler services retain
+normal Symfony autowiring, worker messages use the standard PSR logger when it
+is installed, and `WorkerDiagnosticEvent` is dispatched through Symfony's event
+dispatcher under the diagnostic name. A `KernelTestCase` can use
+`InteractsWithDurableWorkflow::fakeDurableWorkflow()` to replace the autowired
+interface with the same assertion-capable fake used by plain PHP and Laravel.
+
+Both console commands accept `--queue` and `--poll-timeout`. They require
+`ext-pcntl` so SIGINT and SIGTERM always request a graceful worker shutdown.
+Configuration errors, an unreachable endpoint, rejected credentials, and
+worker-protocol or contract mismatches are reported with remediation specific
+to the failing boundary. Neither bridge stores workflow state or installs the
+embedded Laravel workflow engine.
+
+## Test workflow code and interactions
+
+The testing namespace has no PHPUnit dependency. Its assertions throw
+`DurableWorkflow\Testing\AssertionFailed`, so they work with PHPUnit, Pest, or
+plain PHP. Application services can type their dependency as
+`WorkflowClientInterface`; both the network `Client` and `WorkflowClientFake`
+implement that interface and return handles with the same interaction methods.
+
+```php
+use DurableWorkflow\Testing\WorkerTestHarness;
+use DurableWorkflow\Testing\WorkflowClientFake;
+
+$worker = Worker::create($client, 'php-workers')
+ ->register(GreeterWorkflow::class, GreetingActivities::class);
+$handlers = new WorkerTestHarness($worker);
+$handlers->assertWorkflowEmits('greeter', 'schedule_activity', ['Ada']);
+$handlers->assertActivityResult('greet', 'hello, Ada', ['Ada']);
+$handlers->assertQueryResult('greeter', 'status', ['events' => 0]);
+$handlers->assertUpdateResult('greeter', 'rename', 'Grace', ['Grace']);
+$handlers->assertRegistered('signal', 'set-language', 'greeter');
+
+$workflows = (new WorkflowClientFake())
+ ->setQueryResult('greeting-1', 'status', 'running')
+ ->setUpdateResult('greeting-1', 'rename', 'accepted')
+ ->setWorkflowResult('greeting-1', ['greeting' => 'hello, Ada']);
+$handle = $workflows->startWorkflow('greeter', 'greeting-1', 'php-workers', ['Ada']);
+$handle->signal('set-language', ['en']);
+$handle->query('status');
+$handle->update('rename', ['Grace']);
+$handle->result();
+
+$workflows->assertWorkflowStarted('greeter', ['Ada']);
+$workflows->assertSignalSent('greeting-1', 'set-language', ['en']);
+$workflows->assertQueryRequested('greeting-1', 'status');
+$workflows->assertUpdateRequested('greeting-1', 'rename', ['Grace']);
+$workflows->assertResultRequested('greeting-1');
+```
+
+Workflow tasks additionally require an acknowledged lease renewal before user
+code runs. Typed transient renewal pressure is retried with the original task
+ID, attempt, and lease owner; shutdown or a terminal/lost lease prevents task
+execution and completion.
+
+See [`examples/`](../examples/), the authored
+[PHP developer portal](https://php.durable-workflow.com/), the generated
+[API reference](https://php.durable-workflow.com/api/), and
+[`docs/protocol.md`](protocol.md) for the complete client, schedule,
+namespace, visibility, search-attribute, service-operation, discovery,
+authentication, worker, query, and update surfaces.
+
+## Development
+
+```bash
+composer install
+composer validate --strict
+composer test
+composer analyse
+composer docs
+```
+
+The dependency-boundary check rejects Laravel, Illuminate, the embedded
+workflow package, and the standalone server package in both declared and
+resolved production dependencies.
diff --git a/package.json b/package.json
index 6c8b718..f35d45c 100644
--- a/package.json
+++ b/package.json
@@ -6,12 +6,7 @@
"check:docs": "node scripts/check-docs-portal.mjs build/site",
"check:docs-browser": "node scripts/check-docs-browser.mjs build/site",
"check:docs-examples": "node scripts/check-docs-examples.mjs",
- "check:docs-analytics-browser": "node scripts/check-docs-analytics-browser.mjs",
- "qualify:quickstart-contract-deployment": "node scripts/qualify-quickstart-contract-deployment.mjs",
- "test:docs-browser-failures": "node --test scripts/check-docs-browser-failures.test.mjs",
- "qualify:docs-analytics-deployment": "node scripts/qualify-docs-analytics-deployment.mjs",
- "test:quickstart-contract-deployment": "node --test scripts/qualify-quickstart-contract-deployment.test.mjs",
- "test:docs-analytics-deployment": "node --test scripts/qualify-docs-analytics-deployment.test.mjs"
+ "test:docs-browser-failures": "node --test scripts/check-docs-browser-failures.test.mjs"
},
"devDependencies": {
"@11ty/eleventy": "3.1.6",
diff --git a/scripts/check-docs-analytics-browser.mjs b/scripts/check-docs-analytics-browser.mjs
deleted file mode 100644
index 39bc431..0000000
--- a/scripts/check-docs-analytics-browser.mjs
+++ /dev/null
@@ -1,816 +0,0 @@
-import assert from 'node:assert/strict';
-import {spawn} from 'node:child_process';
-import {once} from 'node:events';
-import {readFile, unlink, writeFile} from 'node:fs/promises';
-import http from 'node:http';
-import net from 'node:net';
-import path from 'node:path';
-import process from 'node:process';
-import {chromium} from 'playwright';
-import {
- assertNoBrowserFailures,
- formatHttpFailure,
- formatRequestFailure,
-} from './check-docs-browser-failures.mjs';
-
-const SITE_HOSTNAME = 'php.durable-workflow.com';
-const TEST_TOKEN = '00000000000000000000000000000000';
-const BEACON_URL = 'https://static.cloudflareinsights.com/beacon.min.js';
-const RUM_URL = 'https://cloudflareinsights.com/cdn-cgi/rum';
-const PROMOTION_EVENT_URL = 'https://cloud.durable-workflow.com/early-access/promotion-events';
-const PROMOTION_SOURCE = 'sdk-php-reference';
-const cycleCount = 2;
-const buildDirectory = path.resolve(process.argv[2] ?? 'build/api');
-const runtimeTemplate = (await readFile(path.join(buildDirectory, 'analytics/analytics.js'), 'utf8'))
- .replace('__CLOUDFLARE_WEB_ANALYTICS_TOKEN__', TEST_TOKEN);
-const promotionRequests = [];
-let expectedPromotionOrigin;
-let runtimeSource;
-const viewports = [
- ['compact-height', {width: 1280, height: 360}],
- ['desktop', {width: 1440, height: 900}],
- ['intermediate-landscape', {width: 1024, height: 768}],
- ['intermediate-portrait', {width: 768, height: 1024}],
- ['mobile', {width: 390, height: 844}],
- ['compact-mobile', {width: 320, height: 844}],
- ['compact-narrow-height', {width: 640, height: 360}],
- ['compact-mobile-height', {width: 390, height: 360}],
-];
-const pages = [
- ['neighboring API', '/api/classes/DurableWorkflow-Worker-WorkflowContext.html', 'api'],
- ['Client API', '/api/classes/DurableWorkflow-Client.html', 'api'],
- ['root', '/index.html', 'portal'],
-];
-
-async function availablePort() {
- const server = net.createServer();
- server.unref();
- server.listen(0, '127.0.0.1');
- await once(server, 'listening');
- const address = server.address();
- assert(address && typeof address === 'object');
- await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
- return address.port;
-}
-
-async function waitForServer(port) {
- for (let attempt = 0; attempt < 50; attempt += 1) {
- try {
- await new Promise((resolve, reject) => {
- const socket = net.connect(port, '127.0.0.1');
- socket.once('connect', () => { socket.destroy(); resolve(); });
- socket.once('error', reject);
- });
- return;
- } catch (_error) {
- await new Promise(resolve => setTimeout(resolve, 100));
- }
- }
- throw new Error(`PHP documentation server did not start on port ${port}.`);
-}
-
-async function waitForPromotionRequests(count) {
- for (let attempt = 0; attempt < 50; attempt += 1) {
- if (promotionRequests.length >= count) return;
- await new Promise(resolve => setTimeout(resolve, 100));
- }
- throw new Error(`Promotion receiver recorded ${promotionRequests.length} of ${count} expected requests.`);
-}
-
-function expectedPromotionRequest(event) {
- return {
- accepted: true,
- body: {source: PROMOTION_SOURCE, event},
- headers: {
- authorization: null,
- contentType: 'text/plain',
- cookie: null,
- origin: expectedPromotionOrigin,
- referer: `${expectedPromotionOrigin}/`,
- secFetchMode: null,
- secFetchSite: null,
- },
- method: 'POST',
- path: '/early-access/promotion-events',
- };
-}
-
-async function assertReachableControls(page, label, scope = 'body') {
- const result = await page.evaluate((scopeSelector) => {
- const selector = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [role="button"]';
- const unreachable = [];
- const root = document.querySelector(scopeSelector);
- for (const element of root?.querySelectorAll(selector) || []) {
- if (element.closest('[inert]')) continue;
- if (
- element.closest('.phpdocumentor-sidebar')
- && !document.querySelector('.phpdocumentor-sidebar__menu-button')?.checked
- ) continue;
- const style = getComputedStyle(element);
- const box = element.getBoundingClientRect();
- const visibleBox = {
- top: Math.max(0, box.top),
- right: Math.min(innerWidth, box.right),
- bottom: Math.min(innerHeight, box.bottom),
- left: Math.max(0, box.left),
- };
- let excludedByAncestor = false;
- let clippedByAncestor = false;
- for (let parent = element.parentElement; parent; parent = parent.parentElement) {
- const parentStyle = getComputedStyle(parent);
- excludedByAncestor ||= parentStyle.visibility === 'hidden'
- || parentStyle.display === 'none'
- || parentStyle.pointerEvents === 'none'
- || Number(parentStyle.opacity) === 0;
- const parentBox = parent.getBoundingClientRect();
- if (['auto', 'hidden', 'scroll', 'clip'].includes(parentStyle.overflowX)) {
- clippedByAncestor ||= parentBox.left > box.left || parentBox.right < box.right;
- visibleBox.left = Math.max(visibleBox.left, parentBox.left);
- visibleBox.right = Math.min(visibleBox.right, parentBox.right);
- }
- if (['auto', 'hidden', 'scroll', 'clip'].includes(parentStyle.overflowY)) {
- clippedByAncestor ||= parentBox.top > box.top || parentBox.bottom < box.bottom;
- visibleBox.top = Math.max(visibleBox.top, parentBox.top);
- visibleBox.bottom = Math.min(visibleBox.bottom, parentBox.bottom);
- }
- }
- if (
- excludedByAncestor
- || style.visibility === 'hidden'
- || style.display === 'none'
- || style.pointerEvents === 'none'
- || Number(style.opacity) === 0
- || box.width < 1
- || box.height < 1
- || box.right <= 0
- || box.left >= innerWidth
- || box.bottom <= 0
- || box.top >= innerHeight
- ) continue;
- const visibleWidth = Math.max(0, visibleBox.right - visibleBox.left);
- const visibleHeight = Math.max(0, visibleBox.bottom - visibleBox.top);
- if (visibleWidth <= 0 || visibleHeight <= 0) continue;
-
- const center = {x: box.left + box.width / 2, y: box.top + box.height / 2};
- if (center.x < 0 || center.x >= innerWidth || center.y < 0 || center.y >= innerHeight) continue;
- const centerHit = center.x >= visibleBox.left
- && center.x < visibleBox.right
- && center.y >= visibleBox.top
- && center.y < visibleBox.bottom
- ? document.elementFromPoint(center.x, center.y)
- : null;
- const centerReachable = Boolean(centerHit === element
- || element.contains(centerHit)
- || centerHit?.contains(element));
- if (!centerReachable) {
- unreachable.push({
- element: element.outerHTML.slice(0, 180),
- clippedByAncestor,
- box: box.toJSON(),
- visibleBox,
- });
- }
- }
- return {
- unreachable,
- viewportWidth: document.documentElement.clientWidth,
- documentWidth: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth),
- overflowing: [...document.querySelectorAll('body *')].flatMap(element => {
- const box = element.getBoundingClientRect();
- return box.right > innerWidth + 1 || box.left < -1
- ? [{element: element.outerHTML.slice(0, 180), box: box.toJSON()}]
- : [];
- }).slice(0, 10),
- };
- }, scope);
- assert.equal(
- result.documentWidth,
- result.viewportWidth,
- `${label} has horizontal overflow: ${JSON.stringify(result.overflowing)}`,
- );
- assert.deepEqual(result.unreachable, [], `${label} has unreachable controls`);
-}
-
-async function assertPromotionActionContainment(page, label) {
- const result = await page.evaluate(() => {
- const promotion = document.querySelector('.dw-cloud-promotion');
- const action = promotion?.querySelector('.dw-cloud-promotion__action');
- if (!promotion || !action) return {present: false};
-
- const promotionBox = promotion.getBoundingClientRect();
- const actionBox = action.getBoundingClientRect();
- return {
- present: true,
- promotionBox: promotionBox.toJSON(),
- actionBox: actionBox.toJSON(),
- contained: actionBox.left >= promotionBox.left - 1
- && actionBox.right <= promotionBox.right + 1,
- };
- });
-
- assert.equal(result.present, true, `${label} lost its Cloud promotion`);
- assert.equal(
- result.contained,
- true,
- `${label} promotion action escaped its card: ${JSON.stringify(result)}`,
- );
-}
-
-async function assertTableOfContentsMetadataLegibility(page, label) {
- const result = await page.evaluate(() => {
- const readableWidth = 12 * Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
- const failures = [...document.querySelectorAll(
- '.phpdocumentor-content > section:first-of-type .phpdocumentor-table-of-contents__entry',
- )].flatMap(entry => {
- const metadata = entry.querySelector(':scope > span');
- if (!metadata?.textContent.trim()) return [];
-
- const entryBox = entry.getBoundingClientRect();
- const metadataBox = metadata.getBoundingClientRect();
- const minimumWidth = Math.min(readableWidth, entryBox.width);
- return metadataBox.width + 1 < minimumWidth
- ? [{
- entry: entry.outerHTML.slice(0, 180),
- entryWidth: entryBox.width,
- metadataWidth: metadataBox.width,
- minimumWidth,
- }]
- : [];
- });
-
- return {
- metadataCount: document.querySelectorAll(
- '.phpdocumentor-content > section:first-of-type .phpdocumentor-table-of-contents__entry > span',
- ).length,
- failures,
- };
- });
-
- assert.ok(result.metadataCount > 0, `${label} lost its table-of-contents metadata`);
- assert.deepEqual(result.failures, [], `${label} collapsed table-of-contents metadata`);
-}
-
-async function assertPrimaryApiContentUsesAvailableWidth(page, label) {
- const result = await page.evaluate(() => {
- const content = document.querySelector('.phpdocumentor-content');
- const primary = content?.querySelector(':scope > section:first-of-type');
- const onThisPage = content?.querySelector(':scope > .phpdocumentor-on-this-page__sidebar');
- if (!content || !primary || !onThisPage) return {present: false};
-
- const contentStyle = getComputedStyle(content);
- const onThisPageStyle = getComputedStyle(onThisPage);
- const onThisPageBox = onThisPage.getBoundingClientRect();
- const primaryBox = primary.getBoundingClientRect();
- const availableWidth = content.clientWidth
- - Number.parseFloat(contentStyle.paddingLeft)
- - Number.parseFloat(contentStyle.paddingRight);
-
- return {
- present: true,
- availableWidth,
- primaryWidth: primaryBox.width,
- onThisPageVisible: onThisPageStyle.display !== 'none' && onThisPageBox.width > 0,
- };
- });
-
- assert.equal(result.present, true, `${label} lost its primary API content`);
- if (result.onThisPageVisible) return;
- assert.ok(
- Math.abs(result.primaryWidth - result.availableWidth) <= 1,
- `${label} primary API content does not use its available width: ${JSON.stringify(result)}`,
- );
-}
-
-async function assertOnThisPageUtilityReachability(page, label) {
- const result = await page.evaluate(async () => {
- const scrollport = document.querySelector('.phpdocumentor-on-this-page__content');
- if (!scrollport) return {present: false};
-
- const settle = () => new Promise(resolve => {
- requestAnimationFrame(() => requestAnimationFrame(resolve));
- });
- const originalScrollTop = scrollport.scrollTop;
- const initialScrollportBox = scrollport.getBoundingClientRect();
- const failures = [];
- const entries = [...scrollport.querySelectorAll('a[href]')];
-
- for (const entry of entries) {
- let scrollportBox = scrollport.getBoundingClientRect();
- let entryBox = entry.getBoundingClientRect();
- const entryCenterInContent = entryBox.top - scrollportBox.top
- + scrollport.scrollTop
- + entryBox.height / 2;
- scrollport.scrollTop = Math.max(
- 0,
- Math.min(
- scrollport.scrollHeight - scrollport.clientHeight,
- entryCenterInContent - scrollport.clientHeight / 2,
- ),
- );
- await settle();
-
- scrollportBox = scrollport.getBoundingClientRect();
- entryBox = entry.getBoundingClientRect();
- const center = {x: entryBox.left + entryBox.width / 2, y: entryBox.top + entryBox.height / 2};
- const centerInsideScrollport = center.x >= scrollportBox.left
- && center.x < scrollportBox.right
- && center.y >= Math.max(0, scrollportBox.top)
- && center.y < Math.min(innerHeight, scrollportBox.bottom);
- const hit = centerInsideScrollport ? document.elementFromPoint(center.x, center.y) : null;
- const centerReachable = Boolean(hit === entry || entry.contains(hit) || hit?.contains(entry));
- if (!centerInsideScrollport || !centerReachable) {
- failures.push({
- entry: entry.outerHTML.slice(0, 180),
- center,
- centerInsideScrollport,
- centerReachable,
- scrollportBox: scrollportBox.toJSON(),
- });
- }
- }
-
- scrollport.scrollTop = originalScrollTop;
- await settle();
- return {
- present: true,
- viewportHeight: innerHeight,
- scrollportBox: initialScrollportBox.toJSON(),
- entryCount: entries.length,
- failures,
- };
- });
-
- if (!result.present) return;
- assert.ok(result.entryCount > 0, `${label} lost its On this page entries`);
- assert.ok(
- result.scrollportBox.top >= 0 && result.scrollportBox.bottom <= result.viewportHeight - 1,
- `${label} On this page scrollport extends outside the usable viewport: ${JSON.stringify(result.scrollportBox)}`,
- );
- assert.deepEqual(result.failures, [], `${label} has unreachable On this page entries`);
-}
-
-async function assertFloatingUtilitiesClearReadableContent(page, label, scope = '.phpdocumentor-content') {
- const collisions = await page.evaluate((scopeSelector) => {
- function isVisible(element) {
- const style = getComputedStyle(element);
- const box = element.getBoundingClientRect();
- return style.visibility !== 'hidden'
- && style.display !== 'none'
- && style.pointerEvents !== 'none'
- && Number(style.opacity) !== 0
- && box.width > 0
- && box.height > 0
- && box.right > 0
- && box.left < innerWidth
- && box.bottom > 0
- && box.top < innerHeight;
- }
-
- function intersection(first, second) {
- const width = Math.min(first.right, second.right) - Math.max(first.left, second.left);
- const height = Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top);
- return width > 0 && height > 0 ? width * height : 0;
- }
-
- function visibleTextBox(textBox, element) {
- const visible = {
- top: Math.max(0, textBox.top),
- right: Math.min(innerWidth, textBox.right),
- bottom: Math.min(innerHeight, textBox.bottom),
- left: Math.max(0, textBox.left),
- };
- for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) {
- const style = getComputedStyle(ancestor);
- const box = ancestor.getBoundingClientRect();
- if (['auto', 'hidden', 'scroll', 'clip'].includes(style.overflowX)) {
- visible.left = Math.max(visible.left, box.left);
- visible.right = Math.min(visible.right, box.right);
- }
- if (['auto', 'hidden', 'scroll', 'clip'].includes(style.overflowY)) {
- visible.top = Math.max(visible.top, box.top);
- visible.bottom = Math.min(visible.bottom, box.bottom);
- }
- }
- visible.width = Math.max(0, visible.right - visible.left);
- visible.height = Math.max(0, visible.bottom - visible.top);
- return visible;
- }
-
- const controls = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [role="button"]';
- const utilities = [...document.querySelectorAll(controls)].filter(element => {
- const position = getComputedStyle(element).position;
- return isVisible(element)
- && (position === 'fixed' || position === 'sticky' || element.matches('[data-floating-utility], .phpdocumentor-back-to-top'));
- });
- const collisions = [];
- for (const root of document.querySelectorAll(scopeSelector)) {
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
- const parent = node.parentElement;
- if (!parent || !node.textContent.trim() || parent.closest('script, style, .visually-hidden')) continue;
- if (!isVisible(parent)) continue;
- const range = document.createRange();
- range.selectNodeContents(node);
- for (const textBox of range.getClientRects()) {
- const visibleBox = visibleTextBox(textBox, parent);
- if (
- visibleBox.width < 1
- || visibleBox.height < 1
- ) continue;
- for (const utility of utilities) {
- if (utility.contains(parent) || parent.contains(utility)) continue;
- const area = intersection(utility.getBoundingClientRect(), visibleBox);
- if (area > 1) {
- collisions.push({
- utility: utility.outerHTML.slice(0, 180),
- content: node.textContent.trim().replace(/\s+/g, ' ').slice(0, 100),
- overlapArea: Math.round(area),
- utilityBox: utility.getBoundingClientRect().toJSON(),
- contentBox: visibleBox,
- });
- }
- }
- }
- }
- }
- return collisions;
- }, scope);
- assert.deepEqual(collisions, [], `${label} has a floating utility over readable primary content`);
-}
-
-async function floatingUtilityCandidateScrollPositions(page) {
- return page.evaluate(() => {
- const utility = document.querySelector('.phpdocumentor-back-to-top');
- const content = document.querySelector('.phpdocumentor-content');
- if (!utility || !content) return [];
-
- const utilityBox = utility.getBoundingClientRect();
- const maximumScroll = Math.max(0, document.documentElement.scrollHeight - innerHeight);
- const positions = new Set([0, maximumScroll]);
-
- function addCandidate(box) {
- const horizontalOverlap = Math.min(box.right, utilityBox.right) - Math.max(box.left, utilityBox.left);
- if (horizontalOverlap <= 1 || box.width < 1 || box.height < 1) return;
-
- const documentTop = box.top + scrollY;
- const documentBottom = box.bottom + scrollY;
- const centered = documentTop - utilityBox.top - Math.max(0, (utilityBox.height - box.height) / 2);
- const position = Math.max(0, Math.min(maximumScroll, centered));
- const projectedTop = documentTop - position;
- const projectedBottom = documentBottom - position;
- const verticalOverlap = Math.min(projectedBottom, utilityBox.bottom) - Math.max(projectedTop, utilityBox.top);
- if (verticalOverlap > 1) positions.add(Math.round(position));
- }
-
- const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
- const parent = node.parentElement;
- if (!parent || !node.textContent.trim() || parent.closest('script, style, .visually-hidden')) continue;
- const style = getComputedStyle(parent);
- if (
- style.visibility === 'hidden'
- || style.display === 'none'
- || style.pointerEvents === 'none'
- || Number(style.opacity) === 0
- ) continue;
- const range = document.createRange();
- range.selectNodeContents(node);
- for (const box of range.getClientRects()) addCandidate(box);
- }
-
- for (const element of content.querySelectorAll('canvas, img, input, button, select, table, textarea, video')) {
- const style = getComputedStyle(element);
- if (style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) !== 0) {
- addCandidate(element.getBoundingClientRect());
- }
- }
-
- return [...positions].sort((first, second) => first - second);
- });
-}
-
-async function assertFloatingUtilityGeometry(page, label, expectedVisible = true) {
- const utility = page.locator('.phpdocumentor-back-to-top');
- assert.equal(await utility.count(), 1, `${label} lost its back-to-top utility`);
- assert.equal(await utility.isVisible(), expectedVisible, `${label} back-to-top visibility drifted`);
- if (!expectedVisible) return;
-
- const positions = await floatingUtilityCandidateScrollPositions(page);
- for (const position of positions) {
- await page.evaluate(scrollPosition => new Promise(resolve => {
- scrollTo(0, scrollPosition);
- requestAnimationFrame(() => requestAnimationFrame(resolve));
- }), position);
- await assertReachableControls(page, `${label} at scroll ${position}`);
- await assertFloatingUtilitiesClearReadableContent(page, `${label} at scroll ${position}`);
- }
-
- const maximumScroll = positions.at(-1) ?? 0;
- if (maximumScroll > 0) {
- await page.evaluate(scrollPosition => scrollTo(0, scrollPosition), maximumScroll);
- await utility.click();
- await page.waitForFunction(() => scrollY === 0);
- }
-}
-
-async function exercisePage(
- browser,
- origin,
- viewportName,
- viewport,
- pageName,
- pagePath,
- pageKind,
- edgeInjected = false,
- cycle = null,
-) {
- const context = await browser.newContext({viewport, reducedMotion: 'reduce'});
- const page = await context.newPage();
- const consoleErrors = [];
- const pageErrors = [];
- const requestFailures = [];
- const httpErrors = [];
- const rumRequests = [];
- const promotionRequestStart = promotionRequests.length;
- let renderedPagePath = pagePath;
- let edgeFixturePath;
- page.on('console', message => {
- if (message.type() === 'error') consoleErrors.push(message.text());
- });
- page.on('pageerror', error => pageErrors.push(error.message));
- page.on('requestfailed', request => {
- const errorText = request.failure()?.errorText ?? 'unknown failure';
- if (request.url() === promotionEventUrl && errorText === 'net::ERR_ABORTED') return;
- requestFailures.push(formatRequestFailure(request));
- });
- page.on('response', response => {
- if (response.status() >= 400) httpErrors.push(formatHttpFailure(response));
- });
- const cycleLabel = cycle === null ? '' : ` cycle ${cycle}`;
- const label = `${pageName} ${viewportName}${cycleLabel}${edgeInjected ? ' with edge injection' : ''}`;
-
- try {
- await page.route('**/analytics/analytics.js', route => route.fulfill({
- body: runtimeSource,
- contentType: 'application/javascript',
- }));
- await page.route(`${BEACON_URL}*`, route => route.fulfill({
- body: `window.__cloudflareBeaconExecutions = (window.__cloudflareBeaconExecutions || 0) + 1;
- fetch('${RUM_URL}', {method: 'POST', body: '{}'});`,
- contentType: 'application/javascript',
- }));
- await page.route(`${RUM_URL}*`, route => {
- rumRequests.push(route.request().method());
- return route.fulfill({
- status: 200,
- contentType: 'text/plain',
- body: 'ok',
- headers: {'access-control-allow-origin': '*'},
- });
- });
- if (edgeInjected) {
- const renderedHtml = await readFile(path.join(buildDirectory, pagePath), 'utf8');
- const edgeSnippet = ``;
- edgeFixturePath = path.join(path.dirname(path.join(buildDirectory, pagePath)), 'analytics-edge-injection.html');
- renderedPagePath = `${path.posix.dirname(pagePath)}/analytics-edge-injection.html`;
- await writeFile(edgeFixturePath, renderedHtml.replace('
- API reference
-
-
-