├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bin ├── bootstrap.php ├── file-watcher.cjs └── roadrunner-temporal-worker ├── composer.json ├── config └── temporal.php ├── extension.neon ├── phpstan.neon ├── pint.json ├── rector.php ├── src ├── Builder │ ├── ActivityBuilder.php │ ├── ChildWorkflowBuilder.php │ ├── DefaultRetryPolicy.php │ ├── LocalActivityBuilder.php │ └── WorkflowBuilder.php ├── Commands │ ├── ActivityMakeCommand.php │ ├── Concerns │ │ ├── InteractsWithIO.php │ │ ├── RoadrunnerDependencies.php │ │ └── Stubs.php │ ├── InstallCommand.php │ ├── InterceptorMakeCommand.php │ ├── TestServerCommand.php │ ├── WorkCommand.php │ ├── WorkflowMakeCommand.php │ └── stubs │ │ ├── activity.stub │ │ ├── activity_interface.stub │ │ ├── interceptor.stub │ │ ├── local_activity.stub │ │ ├── local_activity_interface.stub │ │ ├── workflow.stub │ │ └── workflow_interface.stub ├── Contracts │ ├── Temporal.php │ └── TemporalSerializable.php ├── DataConverter │ └── LaravelPayloadConverter.php ├── Exceptions │ └── TemporalSerializerException.php ├── Facade │ └── Temporal.php ├── Integrations │ ├── Eloquent │ │ ├── TemporalEloquentSerialize.php │ │ └── TemporalEloquentSerializer.php │ └── LaravelData │ │ ├── TemporalSerializableCast.php │ │ ├── TemporalSerializableCastAndTransformer.php │ │ └── TemporalSerializableTransformer.php ├── Interceptors │ └── ApplicationSandboxInterceptor.php ├── LaravelTemporalServiceProvider.php ├── PHPStan │ ├── TemporalActivityProxyExtension.php │ ├── TemporalChildWorkflowProxyExtension.php │ ├── TemporalWorkflowClientInterfaceExtension.php │ ├── TemporalWorkflowContextInterfaceExtension.php │ └── TemporalWorkflowProxyExtension.php ├── Support │ ├── ApplicationFactory.php │ ├── CurrentApplication.php │ ├── DiscoverActivities.php │ ├── DiscoverWorkflows.php │ ├── PosixExtension.php │ ├── RoadRunnerBinaryHelper.php │ ├── ServerProcessInspector.php │ └── ServerStateFile.php ├── Temporal.php ├── TemporalRegistry.php └── Testing │ ├── ActivityMock.php │ ├── ActivityMockBuilder.php │ ├── Fakes │ ├── FakeActivityStub.php │ ├── FakeChildWorkflowStub.php │ ├── FakeScopeContext.php │ ├── FakeWorkflowClient.php │ ├── FakeWorkflowContext.php │ ├── FakeWorkflowRun.php │ └── TemporalFake.php │ ├── LocalTemporalServer.php │ ├── TemporalMocker.php │ ├── TemporalMockerCache.php │ ├── TemporalServer.php │ ├── TemporalTestingEnvironment.php │ ├── TemporalTestingWorker.php │ ├── TimeSkippingTemporalServer.php │ ├── WithTemporal.php │ ├── WithTemporalWorker.php │ ├── WorkflowMock.php │ └── WorkflowMockBuilder.php └── stubs └── phpstan ├── ActivityProxy.stub └── ChildWorkflowProxy.stub /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.1.1 - 2025-04-09 4 | 5 | ### What's changed 6 | 7 | - Fix requirement of `spatie/laravel-data` 8 | - Fix interface signature changed 9 | - Fix ci tests 10 | 11 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/1.1.0...1.1.1 12 | 13 | ## v1.1.0 - 2025-02-23 14 | 15 | ### What's changed 16 | 17 | - Allow `temporal/sdk` 2.13 18 | - Support `laravel` 12 19 | 20 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/1.0.3...1.1.0 21 | 22 | ## v.1.0.3 - 2025-02-21 23 | 24 | ### What's Changed 25 | 26 | * Support `thecodingmachine/safe` 3.0 27 | * Bump aglipanci/laravel-pint-action from 2.4 to 2.5 by @dependabot in https://github.com/keepsuit/laravel-temporal/pull/45 28 | 29 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/1.0.2...1.0.3 30 | 31 | ## v1.0.2 - 2025-01-18 32 | 33 | ### What's Changed 34 | 35 | * Phpstan 2 by @cappuc in https://github.com/keepsuit/laravel-temporal/pull/44 36 | 37 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/1.0.1...1.0.2 38 | 39 | ## v1.0.1 - 2025-01-18 40 | 41 | ### What's changed 42 | 43 | - Allow `temporal/sdk` `2.12` 44 | 45 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/1.0.0...1.0.1 46 | 47 | ## 1.0.0-beta2 - 2024-03-15 48 | 49 | ### What's Changed 50 | 51 | * Bump ramsey/composer-install from 2 to 3 by @dependabot in https://github.com/keepsuit/laravel-temporal/pull/34 52 | * Drop laravel data v3 by @cappuc in https://github.com/keepsuit/laravel-temporal/pull/36 53 | * Added support for `temporal/sdk` `v2.8` 54 | 55 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/1.0.0-beta1...1.0.0-beta2 56 | 57 | ## v0.6.14 - 2024-01-03 58 | 59 | ### What's Changed 60 | 61 | * Fixed builders phpdoc 62 | 63 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/0.6.13...0.6.14 64 | 65 | ## v0.6.13 - 2024-01-02 66 | 67 | ### What's Changed 68 | 69 | * Conflict with `temporal/sdk:2.7` 70 | * Added `temporal:install` command 71 | * Ignored communication exceptions in worker (thrown when the worker is killed) 72 | * Fixed child workflow result when not mocked in testing environment 73 | * Improved tests 74 | * Bump aglipanci/laravel-pint-action from 2.3.0 to 2.3.1 by @dependabot in https://github.com/keepsuit/laravel-temporal/pull/30 75 | 76 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/0.6.12...0.6.13 77 | 78 | ## v0.6.12 - 2023-12-15 79 | 80 | ### What's Changed 81 | 82 | * Support for namespaces config when starting child workflows and activities. by @slnw in https://github.com/keepsuit/laravel-temporal/pull/28 83 | 84 | ### New Contributors 85 | 86 | * @slnw made their first contribution in https://github.com/keepsuit/laravel-temporal/pull/28 87 | 88 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/0.6.11...0.6.12 89 | 90 | ## v0.6.11 - 2023-11-17 91 | 92 | ### What's Changed 93 | 94 | - Support project using type "module" 95 | 96 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/0.6.10...0.6.11 97 | 98 | ## v0.6.10 - 2023-10-05 99 | 100 | ### What's changed 101 | 102 | - Catch all instantiation errors in `LaravalPayloadConverter` and fallback to `JsonConverter` 103 | 104 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/0.6.9...0.6.10 105 | 106 | ## v0.6.9 - 2023-09-25 107 | 108 | ### What's Changed 109 | 110 | - Fix commands in ReadMe 📚 by @michael-rubel in https://github.com/keepsuit/laravel-temporal/pull/15 111 | - Remove issue template config by @michael-rubel in https://github.com/keepsuit/laravel-temporal/pull/17 112 | - Temporal root namespace by @cappuc in https://github.com/keepsuit/laravel-temporal/pull/18 113 | 114 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/0.6.8...0.6.9 115 | 116 | ## v0.6.8 - 2023-09-19 117 | 118 | ### What's changed 119 | 120 | - Fixed required wrong temporal sdk version 121 | 122 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/0.6.7...0.6.8 123 | 124 | ## v0.6.7 - 2023-09-19 125 | 126 | ### What's Changed 127 | 128 | - Fix testing with `temporal/sdk:2.6.0` 129 | 130 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/0.6.6...0.6.7 131 | 132 | ## v0.6.6 - 2023-04-28 133 | 134 | ### What's changed 135 | 136 | - Set correct roadrunner config version when using v3 137 | 138 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/0.6.5...0.6.6 139 | 140 | ## v0.6.5 - 2023-04-27 141 | 142 | ### What's changed 143 | 144 | - Support roadrunner v3 145 | 146 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/0.6.4...0.6.5 147 | 148 | ## v0.6.4 - 2023-04-12 149 | 150 | ### What's Changed 151 | 152 | - Ensure roadrunner binary is executable 153 | - Improved error messages for testing processes fail 154 | 155 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/0.6.3...0.6.4 156 | 157 | ## v0.6.3 - 2023-04-05 158 | 159 | ### What's Changed 160 | 161 | - Add more verbose message for the user if worker crashes https://github.com/keepsuit/laravel-temporal/pull/12 162 | 163 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/0.6.2...0.6.3 164 | 165 | ## v0.6.2 - 2023-03-24 166 | 167 | ### What's Changed 168 | 169 | - Improve testing speed using only local cache when testing environment is not configured 170 | 171 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/v0.6.1...0.6.2 172 | 173 | ## v0.6.1 - 2023-03-21 174 | 175 | ### What's Changed 176 | 177 | - Add interface for `Temporal` class 🔧 (https://github.com/keepsuit/laravel-temporal/pull/10) 178 | 179 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/v0.6.0...v0.6.1 180 | 181 | ## v0.6.0 - 2023-03-15 182 | 183 | ### What's Changed 184 | 185 | - Improved phpstan types (https://github.com/keepsuit/laravel-temporal/pull/6) 186 | - Added phpstan extension to resolve temporal proxy classes methods and return types 187 | - Improved test worker handling 188 | - Update trait name in ReadMe 📚 (https://github.com/keepsuit/laravel-temporal/pull/8) 189 | 190 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/0.5.3...v0.6.0 191 | 192 | ## v0.5.3 - 2023-01-25 193 | 194 | ### What's Changed 195 | 196 | - Fix installation step in ReadMe 📝 by @michael-rubel in https://github.com/keepsuit/laravel-temporal/pull/4 197 | - Support laravel 10 by @cappuc in https://github.com/keepsuit/laravel-temporal/pull/5 198 | 199 | ### New Contributors 200 | 201 | - @michael-rubel made their first contribution in https://github.com/keepsuit/laravel-temporal/pull/4 202 | 203 | **Full Changelog**: https://github.com/keepsuit/laravel-temporal/compare/0.5.2...0.5.3 204 | 205 | ## v0.5.2 - 2023-01-05 206 | 207 | - Allow to pass only workflow/activity name for mock 208 | 209 | ## v0.5.1 - 2022-12-06 210 | 211 | - Improved eloquent integration with dirty tracking (globally not attribute specific) 212 | 213 | ## v0.5.0 - 2022-11-29 214 | 215 | - Discover activities and workflows classes (without interface) 216 | - Refactor make commands to generate activities and workflow classes without interface (by default) or only the interface (useful for remote execution) 217 | 218 | ## v0.4.2 - 2022-10-20 219 | 220 | - Support serialization of Enums 221 | 222 | ## v0.4.1 - 2022-10-13 223 | 224 | - Allow to mock workflows without a running temporal server and worker 225 | - Allow to mock local activities 226 | 227 | ## v0.4 228 | 229 | - Added support for `Eloquent` models serialization/deserialization 230 | - Updated configuration file with eloquent serialization options 231 | 232 | ## v0.3 233 | 234 | - Added config option for changing the temporal namespace 235 | - Added config options to allow customization of default retry options for workflows and activities 236 | - Added `--scoped` option to `make` commands to allow generating Workflow/Activity inside a scoped namespace 237 | - Added `--for-workflow` option to `make:activity` command to allow generating Activity inside a Workflow namespace 238 | - Improved app testing performance and added experimental support for parallel testing 239 | 240 | ## v0.2 241 | 242 | - Added testing helpers: activity/workflows mocks, dispatches assertions, automatic test server and worker 243 | 244 | ## v0.1 245 | 246 | - Initial release 247 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) keepsuit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /bin/bootstrap.php: -------------------------------------------------------------------------------- 1 | console.log('File added...')) 11 | .on('change', () => console.log('File changed...')) 12 | .on('unlink', () => console.log('File deleted...')) 13 | .on('unlinkDir', () => console.log('Directory deleted...')); 14 | -------------------------------------------------------------------------------- /bin/roadrunner-temporal-worker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | createApplication(); 18 | CurrentApplication::setRootApp($app); 19 | 20 | if ($app->environment() !== 'production' && env('TEMPORAL_TESTING_ENV')) { 21 | Temporal::initFakeWorker(); 22 | } 23 | 24 | /** @var TemporalRegistry $registry */ 25 | $registry = $app->make(TemporalRegistry::class); 26 | 27 | // factory initiates and runs task queue specific activity and workflow workers 28 | $factory = WorkerFactory::create( 29 | converter: $app->make(DataConverterInterface::class) 30 | ); 31 | 32 | $taskQueue = env('TEMPORAL_QUEUE', \Temporal\WorkerFactory::DEFAULT_TASK_QUEUE); 33 | assert(is_string($taskQueue), 'TEMPORAL_QUEUE must be a string'); 34 | 35 | // Worker that listens on a Task Queue and hosts both workflow and activity implementations. 36 | $worker = $factory->newWorker( 37 | taskQueue: $taskQueue, 38 | options: Temporal::buildWorkerOptions($taskQueue), 39 | interceptorProvider: new SimplePipelineProvider(array_map( 40 | fn (string $className) => $app->make($className), 41 | array_merge([ 42 | ApplicationSandboxInterceptor::class, 43 | ], $app['config']->get('temporal.interceptors', [])) 44 | )) 45 | ); 46 | 47 | foreach ($registry->workflows() as $workflow) { 48 | $worker->registerWorkflowTypes($workflow); 49 | } 50 | 51 | foreach ($registry->activities() as $activity) { 52 | $worker->registerActivity($activity, fn (ReflectionClass $class) => $app->make($class->getName())); 53 | } 54 | 55 | try { 56 | $factory->run(); 57 | } catch (RelayException $e) { 58 | exit(1); 59 | } 60 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "keepsuit/laravel-temporal", 3 | "description": "Laravel temporal.io", 4 | "keywords": [ 5 | "keepsuit", 6 | "laravel", 7 | "laravel-temporal" 8 | ], 9 | "homepage": "https://github.com/keepsuit/laravel-temporal", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Fabio Capucci", 14 | "email": "f.capucci@keepsuit.com", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.1", 20 | "composer/class-map-generator": "^1.0", 21 | "illuminate/contracts": "^10.0 || ^11.0 || ^12.0", 22 | "spatie/laravel-package-tools": "^1.14.0", 23 | "spiral/roadrunner": "^2023.2 || ^2024.0", 24 | "spiral/roadrunner-cli": "^2.5", 25 | "symfony/process": "^6.0 || ^7.0", 26 | "temporal/sdk": ">=2.7.4 <2.14.0", 27 | "thecodingmachine/safe": "^2.0 || ^3.0" 28 | }, 29 | "require-dev": { 30 | "dereuromark/composer-prefer-lowest": "^0.1.10", 31 | "larastan/larastan": "^2.9 || ^3.0", 32 | "laravel/pint": "^1.17", 33 | "mockery/mockery": "^1.6", 34 | "nesbot/carbon": "^2.63 || ^3.0", 35 | "nunomaduro/collision": "^7.0 || ^8.0 || ^9.0", 36 | "orchestra/testbench": "^8.0 || ^9.0 || ^10.0", 37 | "pestphp/pest": "^2.35 || ^3.0", 38 | "pestphp/pest-plugin-laravel": "^2.4 || ^3.0", 39 | "phpstan/extension-installer": "^1.4", 40 | "phpstan/phpstan-deprecation-rules": "^1.2 || ^2.0", 41 | "phpstan/phpstan": "^1.12 || ^2.0", 42 | "rector/rector": "^1.2 || ^2.0", 43 | "spatie/invade": "^2.0", 44 | "spatie/laravel-data": "^4.3", 45 | "spatie/laravel-ray": "^1.26", 46 | "thecodingmachine/phpstan-safe-rule": "^1.2" 47 | }, 48 | "suggest": { 49 | "spatie/laravel-data": "Can be used for workflows payloads (recommended ^4.3)" 50 | }, 51 | "autoload": { 52 | "psr-4": { 53 | "Keepsuit\\LaravelTemporal\\": "src" 54 | } 55 | }, 56 | "autoload-dev": { 57 | "psr-4": { 58 | "Keepsuit\\LaravelTemporal\\Tests\\": "tests" 59 | } 60 | }, 61 | "bin": [ 62 | "bin/roadrunner-temporal-worker" 63 | ], 64 | "scripts": { 65 | "post-autoload-dump": [ 66 | "@clear", 67 | "@prepare" 68 | ], 69 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", 70 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 71 | "test": "pest", 72 | "test-coverage": "pest --coverage", 73 | "lint": [ 74 | "pint", 75 | "rector process --dry-run", 76 | "phpstan analyse" 77 | ], 78 | "lint:fix": [ 79 | "pint", 80 | "rector process", 81 | "phpstan analyse" 82 | ] 83 | }, 84 | "config": { 85 | "sort-packages": true, 86 | "allow-plugins": { 87 | "pestphp/pest-plugin": true, 88 | "phpstan/extension-installer": true 89 | } 90 | }, 91 | "extra": { 92 | "laravel": { 93 | "providers": [ 94 | "Keepsuit\\LaravelTemporal\\LaravelTemporalServiceProvider" 95 | ] 96 | }, 97 | "phpstan": { 98 | "includes": [ 99 | "extension.neon" 100 | ] 101 | } 102 | }, 103 | "minimum-stability": "dev", 104 | "prefer-stable": true 105 | } 106 | -------------------------------------------------------------------------------- /config/temporal.php: -------------------------------------------------------------------------------- 1 | env('TEMPORAL_ADDRESS', '127.0.0.1:7233'), 8 | 9 | /** 10 | * TLS configuration (optional) 11 | * Allows to configure the client to use a secure connection to the server. 12 | */ 13 | 'tls' => [ 14 | /** 15 | * Path to the client key file (/path/to/client.key) 16 | */ 17 | 'client_key' => env('TEMPORAL_TLS_CLIENT_KEY'), 18 | /** 19 | * Path to the client cert file (/path/to/client.pem) 20 | */ 21 | 'client_cert' => env('TEMPORAL_TLS_CLIENT_CERT'), 22 | /** 23 | * Path to the root CA certificate file (/path/to/ca.cert) 24 | */ 25 | 'root_ca' => env('TEMPORAL_TLS_ROOT_CA'), 26 | /** 27 | * Override server name (default is hostname) to verify against the server certificate 28 | */ 29 | 'server_name' => env('TEMPORAL_TLS_SERVER_NAME'), 30 | ], 31 | 32 | /** 33 | * Temporal namespace 34 | */ 35 | 'namespace' => env('TEMPORAL_NAMESPACE', \Temporal\Client\ClientOptions::DEFAULT_NAMESPACE), 36 | 37 | /** 38 | * Default task queue 39 | */ 40 | 'queue' => \Temporal\WorkerFactory::DEFAULT_TASK_QUEUE, 41 | 42 | /** 43 | * Default retry policy 44 | */ 45 | 'retry' => [ 46 | 47 | /** 48 | * Default retry policy for workflows 49 | */ 50 | 'workflow' => [ 51 | /** 52 | * Initial retry interval (in seconds) 53 | * Default: 1 54 | */ 55 | 'initial_interval' => null, 56 | 57 | /** 58 | * Retry interval increment 59 | * Default: 2.0 60 | */ 61 | 'backoff_coefficient' => null, 62 | 63 | /** 64 | * Maximum interval before fail 65 | * Default: 100 x initial_interval 66 | */ 67 | 'maximum_interval' => null, 68 | 69 | /** 70 | * Maximum attempts 71 | * Default: unlimited 72 | */ 73 | 'maximum_attempts' => null, 74 | ], 75 | 76 | /** 77 | * Default retry policy for activities 78 | */ 79 | 'activity' => [ 80 | /** 81 | * Initial retry interval (in seconds) 82 | * Default: 1 83 | */ 84 | 'initial_interval' => null, 85 | 86 | /** 87 | * Retry interval increment 88 | * Default: 2.0 89 | */ 90 | 'backoff_coefficient' => null, 91 | 92 | /** 93 | * Maximum interval before fail 94 | * Default: 100 x initial_interval 95 | */ 96 | 'maximum_interval' => null, 97 | 98 | /** 99 | * Maximum attempts 100 | * Default: unlimited 101 | */ 102 | 'maximum_attempts' => null, 103 | ], 104 | ], 105 | 106 | /** 107 | * Interceptors (middlewares) registered in the worker 108 | */ 109 | 'interceptors' => [ 110 | ], 111 | 112 | /** 113 | * Manual register workflows 114 | */ 115 | 'workflows' => [ 116 | ], 117 | 118 | /** 119 | * Manual register activities 120 | */ 121 | 'activities' => [ 122 | ], 123 | 124 | /** 125 | * Directories to watch when server is started with `--watch` flag 126 | */ 127 | 'watch' => [ 128 | 'app', 129 | 'config', 130 | ], 131 | 132 | /** 133 | * Integrations options 134 | */ 135 | 'integrations' => [ 136 | 137 | /** 138 | * Eloquent models serialization/deserialization options 139 | */ 140 | 'eloquent' => [ 141 | /** 142 | * Default attribute key case conversion when serialize a model before sending to temporal. 143 | * Supported values: 'snake', 'camel', null. 144 | */ 145 | 'serialize_attribute_case' => null, 146 | 147 | /** 148 | * Default attribute key case conversion when deserializing payload received from temporal. 149 | * Supported values: 'snake', 'camel', null. 150 | */ 151 | 'deserialize_attribute_case' => null, 152 | 153 | /** 154 | * If true adds additional metadata fields (`__exists`, `__dirty`) to the serialized model to improve deserialization. 155 | * `__exists`: indicate that the model is saved to database. 156 | * `__dirty`: indicate that the model has unsaved changes. (original values are not included in the serialized payload but the deserialized model will be marked as dirty) 157 | */ 158 | 'include_metadata_field' => false, 159 | ], 160 | ], 161 | 162 | /** 163 | * Testing 164 | */ 165 | 'testing' => [ 166 | /** 167 | * Run the temporal server in testing environment, set to false to use an external server. 168 | */ 169 | 'server' => env('TEMPORAL_TESTING_SERVER', true), 170 | 171 | /** 172 | * Enable time skipping 173 | */ 174 | 'time_skipping' => env('TEMPORAL_TESTING_SERVER_TIME_SKIPPING', false), 175 | 176 | /** 177 | * Enable debug output 178 | */ 179 | 'debug' => env('TEMPORAL_TESTING_DEBUG', false), 180 | ], 181 | ]; 182 | -------------------------------------------------------------------------------- /extension.neon: -------------------------------------------------------------------------------- 1 | services: 2 | - 3 | class: Keepsuit\LaravelTemporal\PHPStan\TemporalActivityProxyExtension 4 | tags: 5 | - phpstan.broker.methodsClassReflectionExtension 6 | - 7 | class: Keepsuit\LaravelTemporal\PHPStan\TemporalWorkflowProxyExtension 8 | tags: 9 | - phpstan.broker.methodsClassReflectionExtension 10 | - 11 | class: Keepsuit\LaravelTemporal\PHPStan\TemporalChildWorkflowProxyExtension 12 | tags: 13 | - phpstan.broker.methodsClassReflectionExtension 14 | - 15 | class: Keepsuit\LaravelTemporal\PHPStan\TemporalWorkflowContextInterfaceExtension 16 | tags: 17 | - phpstan.broker.dynamicMethodReturnTypeExtension 18 | - 19 | class: Keepsuit\LaravelTemporal\PHPStan\TemporalWorkflowClientInterfaceExtension 20 | tags: 21 | - phpstan.broker.dynamicMethodReturnTypeExtension 22 | 23 | parameters: 24 | stubFiles: 25 | - stubs/phpstan/ActivityProxy.stub 26 | - stubs/phpstan/ChildWorkflowProxy.stub 27 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - extension.neon 3 | - phpstan-baseline.neon 4 | 5 | parameters: 6 | level: 7 7 | paths: 8 | - src 9 | - config 10 | tmpDir: build/phpstan 11 | checkOctaneCompatibility: true 12 | checkModelProperties: true 13 | treatPhpDocTypesAsCertain: false 14 | 15 | excludePaths: 16 | - src/Integrations/LaravelData/TemporalSerializableCastAndTransformer.php 17 | 18 | ignoreErrors: 19 | - identifier: trait.unused 20 | - identifier: missingType.generics 21 | - identifier: missingType.iterableValue 22 | - identifier: larastan.noEnvCallsOutsideOfConfig 23 | path: config/* 24 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "build" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withCache(__DIR__.'/build/rector') 18 | ->withPaths([ 19 | __DIR__.'/src', 20 | __DIR__.'/config', 21 | ]) 22 | ->withPhpSets() 23 | ->withPreparedSets( 24 | deadCode: true, 25 | codeQuality: true, 26 | codingStyle: true, 27 | typeDeclarations: true, 28 | ) 29 | ->withSkip([ 30 | AddArrowFunctionReturnTypeRector::class, 31 | AddParamBasedOnParentClassMethodRector::class, 32 | ClosureToArrowFunctionRector::class, 33 | FlipTypeControlToUseExclusiveTypeRector::class, 34 | IfIssetToCoalescingRector::class, 35 | RemoveNullPropertyInitializationRector::class, 36 | ReturnTypeFromReturnDirectArrayRector::class, 37 | ReturnTypeFromReturnNewRector::class, 38 | ReturnTypeFromStrictTypedCallRector::class, 39 | ]); 40 | -------------------------------------------------------------------------------- /src/Builder/ActivityBuilder.php: -------------------------------------------------------------------------------- 1 | activityOptions = ActivityOptions::new() 41 | ->withTaskQueue(config('temporal.queue')) 42 | ->withRetryOptions($this->getDefaultRetryOptions(config('temporal.retry.activity'))); 43 | } 44 | 45 | public static function new(): ActivityBuilder 46 | { 47 | return new ActivityBuilder; 48 | } 49 | 50 | public static function newLocal(): LocalActivityBuilder 51 | { 52 | return LocalActivityBuilder::new(); 53 | } 54 | 55 | /** 56 | * @template T of object 57 | * 58 | * @param class-string $class 59 | * @return ActivityProxy 60 | */ 61 | public function build(string $class): ActivityProxy 62 | { 63 | return Temporal::getTemporalContext()->newActivityStub($class, $this->activityOptions); 64 | } 65 | 66 | public function buildUntyped(): ActivityStubInterface 67 | { 68 | return Temporal::getTemporalContext()->newUntypedActivityStub($this->activityOptions); 69 | } 70 | 71 | public function __call(string $name, array $arguments): self 72 | { 73 | if (method_exists($this->activityOptions, $name)) { 74 | $self = clone $this; 75 | 76 | $self->activityOptions = $self->activityOptions->{$name}(...$arguments); 77 | 78 | return $self; 79 | } 80 | 81 | throw new InvalidArgumentException(sprintf('Method %s does not exists', $name)); 82 | } 83 | 84 | public function __get(string $name): mixed 85 | { 86 | if (property_exists($this->activityOptions, $name)) { 87 | return $this->activityOptions->{$name}; 88 | } 89 | 90 | throw new InvalidArgumentException(sprintf('Property %s does not exists', $name)); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Builder/ChildWorkflowBuilder.php: -------------------------------------------------------------------------------- 1 | workflowOptions = ChildWorkflowOptions::new() 51 | ->withNamespace(config('temporal.namespace')) 52 | ->withTaskQueue(config('temporal.queue')) 53 | ->withRetryOptions($this->getDefaultRetryOptions(config('temporal.retry.workflow'))); 54 | } 55 | 56 | public static function new(): ChildWorkflowBuilder 57 | { 58 | return new ChildWorkflowBuilder; 59 | } 60 | 61 | /** 62 | * @template T of object 63 | * 64 | * @param class-string $class 65 | * @return ChildWorkflowProxy 66 | */ 67 | public function build(string $class): ChildWorkflowProxy 68 | { 69 | return Temporal::getTemporalContext()->newChildWorkflowStub($class, $this->workflowOptions); 70 | } 71 | 72 | public function buildUntyped(string $workflowType): ChildWorkflowStubInterface 73 | { 74 | return Temporal::getTemporalContext()->newUntypedChildWorkflowStub($workflowType, $this->workflowOptions); 75 | } 76 | 77 | public function __call(string $name, array $arguments): self 78 | { 79 | if (method_exists($this->workflowOptions, $name)) { 80 | $self = clone $this; 81 | 82 | $self->workflowOptions = $self->workflowOptions->{$name}(...$arguments); 83 | 84 | return $self; 85 | } 86 | 87 | throw new InvalidArgumentException(sprintf('Method %s does not exists', $name)); 88 | } 89 | 90 | public function __get(string $name): mixed 91 | { 92 | if (property_exists($this->workflowOptions, $name)) { 93 | return $this->workflowOptions->{$name}; 94 | } 95 | 96 | throw new InvalidArgumentException(sprintf('Property %s does not exists', $name)); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Builder/DefaultRetryPolicy.php: -------------------------------------------------------------------------------- 1 | withInitialInterval(CarbonInterval::seconds($initialInterval)); 17 | } 18 | 19 | if (is_numeric($backoffCoefficient = Arr::get($config, 'backoff_coefficient'))) { 20 | $retryOptions = $retryOptions->withBackoffCoefficient((float) $backoffCoefficient); 21 | } 22 | 23 | if (is_numeric($maximumInterval = Arr::get($config, 'maximum_interval'))) { 24 | $retryOptions = $retryOptions->withMaximumInterval(CarbonInterval::seconds($maximumInterval)); 25 | } 26 | 27 | if (is_numeric($maximumAttempts = Arr::get($config, 'maximum_attempts'))) { 28 | return $retryOptions->withMaximumAttempts(max(0, (int) $maximumAttempts)); 29 | } 30 | 31 | return $retryOptions; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Builder/LocalActivityBuilder.php: -------------------------------------------------------------------------------- 1 | activityOptions = LocalActivityOptions::new() 31 | ->withRetryOptions($this->getDefaultRetryOptions(config('temporal.retry.activity'))); 32 | } 33 | 34 | public static function new(): LocalActivityBuilder 35 | { 36 | return new LocalActivityBuilder; 37 | } 38 | 39 | /** 40 | * @template T of object 41 | * 42 | * @param class-string $class 43 | * @return ActivityProxy 44 | */ 45 | public function build(string $class): ActivityProxy 46 | { 47 | return Temporal::getTemporalContext()->newActivityStub($class, $this->activityOptions); 48 | } 49 | 50 | public function buildUntyped(): ActivityStubInterface 51 | { 52 | return Temporal::getTemporalContext()->newUntypedActivityStub($this->activityOptions); 53 | } 54 | 55 | public function __call(string $name, array $arguments): self 56 | { 57 | if (method_exists($this->activityOptions, $name)) { 58 | $self = clone $this; 59 | 60 | $self->activityOptions = $self->activityOptions->{$name}(...$arguments); 61 | 62 | return $self; 63 | } 64 | 65 | throw new InvalidArgumentException(sprintf('Method %s does not exists', $name)); 66 | } 67 | 68 | public function __get(string $name): mixed 69 | { 70 | if (property_exists($this->activityOptions, $name)) { 71 | return $this->activityOptions->{$name}; 72 | } 73 | 74 | throw new InvalidArgumentException(sprintf('Property %s does not exists', $name)); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Builder/WorkflowBuilder.php: -------------------------------------------------------------------------------- 1 | workflowOptions = WorkflowOptions::new() 49 | ->withTaskQueue(config('temporal.queue')) 50 | ->withRetryOptions($this->getDefaultRetryOptions(config('temporal.retry.workflow'))); 51 | } 52 | 53 | public static function new(): WorkflowBuilder 54 | { 55 | return new WorkflowBuilder; 56 | } 57 | 58 | public static function newChild(): ChildWorkflowBuilder 59 | { 60 | return new ChildWorkflowBuilder; 61 | } 62 | 63 | public function withRunId(?string $runId): self 64 | { 65 | $self = clone $this; 66 | 67 | $self->runId = $runId; 68 | 69 | return $self; 70 | } 71 | 72 | /** 73 | * @template T of object 74 | * 75 | * @param class-string $class 76 | * @return WorkflowProxy 77 | */ 78 | public function build(string $class): WorkflowProxy 79 | { 80 | if ($this->runId !== null) { 81 | return $this->getWorkflowClient() 82 | ->newRunningWorkflowStub($class, $this->workflowOptions->workflowId, $this->runId); 83 | } 84 | 85 | return $this->getWorkflowClient() 86 | ->newWorkflowStub($class, $this->workflowOptions); 87 | } 88 | 89 | public function buildUntyped(string $workflowType): WorkflowStubInterface 90 | { 91 | if ($this->runId !== null) { 92 | return $this->getWorkflowClient() 93 | ->newUntypedRunningWorkflowStub($this->workflowOptions->workflowId, $this->runId, $workflowType); 94 | } 95 | 96 | return $this->getWorkflowClient() 97 | ->newUntypedWorkflowStub($workflowType, $this->workflowOptions); 98 | } 99 | 100 | public function __call(string $name, array $arguments): self 101 | { 102 | if (method_exists($this->workflowOptions, $name)) { 103 | $self = clone $this; 104 | 105 | $self->workflowOptions = $self->workflowOptions->{$name}(...$arguments); 106 | 107 | return $self; 108 | } 109 | 110 | throw new InvalidArgumentException(sprintf('Method %s does not exists', $name)); 111 | } 112 | 113 | public function __get(string $name): mixed 114 | { 115 | if ($name === 'runId') { 116 | return $this->runId; 117 | } 118 | 119 | if (property_exists($this->workflowOptions, $name)) { 120 | return $this->workflowOptions->{$name}; 121 | } 122 | 123 | throw new InvalidArgumentException(sprintf('Property %s does not exists', $name)); 124 | } 125 | 126 | protected function getWorkflowClient(): WorkflowClientInterface 127 | { 128 | return app(WorkflowClientInterface::class); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Commands/ActivityMakeCommand.php: -------------------------------------------------------------------------------- 1 | option('interface') && $this->option('local') => $this->resolveStubPath('local_activity_interface.stub'), 25 | $this->option('interface') => $this->resolveStubPath('activity_interface.stub'), 26 | $this->option('local') => $this->resolveStubPath('local_activity.stub'), 27 | default => $this->resolveStubPath('activity.stub'), 28 | }; 29 | } 30 | 31 | protected function getDefaultNamespace($rootNamespace): string 32 | { 33 | $rootNamespace = match (true) { 34 | is_dir($this->laravel->path('Temporal/Activities')) => $rootNamespace.'\\Temporal', 35 | ! is_dir($this->laravel->path('Activities')) => $rootNamespace.'\\Temporal', 36 | default => $rootNamespace 37 | }; 38 | 39 | if (is_string($this->option('for-workflow'))) { 40 | $namespace = Str::of($this->option('for-workflow')) 41 | ->whenEndsWith('Workflow', fn ($name) => $name->replaceLast('Workflow', '')); 42 | 43 | return sprintf('%s\\Workflows\\%s', $rootNamespace, $namespace); 44 | } 45 | 46 | if ($this->option('scoped')) { 47 | $namespace = Str::of($this->getNameInput()) 48 | ->whenEndsWith('Activity', fn ($name) => $name->replaceLast('Activity', '')); 49 | 50 | return sprintf('%s\\Activities\\%s', $rootNamespace, $namespace); 51 | } 52 | 53 | return sprintf('%s\\Activities', $rootNamespace); 54 | } 55 | 56 | protected function getNameInput(): string 57 | { 58 | return Str::of(parent::getNameInput()) 59 | ->whenEndsWith('Interface', fn ($name) => $name->replaceLast('Interface', '')) 60 | ->when($this->option('interface'), fn ($name) => $name->append('Interface')); 61 | } 62 | 63 | protected function getOptions(): array 64 | { 65 | return [ 66 | ['interface', 'i', InputOption::VALUE_NONE, 'Create an interface for the activity instead of a class'], 67 | ['local', 'l', InputOption::VALUE_NONE, 'Create a local activity'], 68 | ['scoped', 's', InputOption::VALUE_NONE, 'Create the activity inside a scoped directory'], 69 | ['for-workflow', 'w', InputOption::VALUE_REQUIRED, 'Create the activity in the provided workflow namespace'], 70 | ['force', 'f', InputOption::VALUE_NONE, 'Create the Interceptor class even if the file already exists'], 71 | ]; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Commands/Concerns/InteractsWithIO.php: -------------------------------------------------------------------------------- 1 | ignoreMessages)) { 40 | $this->output->writeln($string); 41 | } 42 | } 43 | 44 | /** 45 | * Write information about a request to the console. 46 | * 47 | * @param array{ 48 | * WorkflowType: array{Name:string}, 49 | * TaskQueueName: string, 50 | * Attempt: int, 51 | * WorkflowStartTime: string 52 | * } $workflowInfo 53 | */ 54 | public function workflowInfo(array $workflowInfo, int|string|null $verbosity = null): void 55 | { 56 | $terminalWidth = $this->getTerminalWidth(); 57 | 58 | $workflowName = $workflowInfo['WorkflowType']['Name']; 59 | // $taskQueue = $workflowInfo['TaskQueueName']; 60 | $attempt = $workflowInfo['Attempt']; 61 | $startAt = Carbon::parse($workflowInfo['WorkflowStartTime'])->toDateTimeString(); 62 | 63 | if ($attempt > 1) { 64 | $dots = str_repeat('.', max($terminalWidth - strlen($workflowName.sprintf('(%d)', $attempt).$startAt) - 7, 3)); 65 | 66 | $this->output->writeln(sprintf( 67 | ' %s (%s) %s %s', 68 | $workflowName, 69 | $attempt, 70 | $dots, 71 | $startAt 72 | ), $this->parseVerbosity($verbosity)); 73 | 74 | return; 75 | } 76 | 77 | $dots = str_repeat('.', max($terminalWidth - strlen($workflowName.$startAt) - 6, 3)); 78 | 79 | $this->output->writeln(sprintf( 80 | ' %s %s %s', 81 | $workflowName, 82 | $dots, 83 | $startAt 84 | ), $this->parseVerbosity($verbosity)); 85 | } 86 | 87 | /** 88 | * Computes the terminal width. 89 | */ 90 | protected function getTerminalWidth(): int 91 | { 92 | if ($this->terminalWidth == null) { 93 | $this->terminalWidth = (new Terminal)->getWidth(); 94 | 95 | $this->terminalWidth = max($this->terminalWidth, 30); 96 | } 97 | 98 | return $this->terminalWidth; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Commands/Concerns/RoadrunnerDependencies.php: -------------------------------------------------------------------------------- 1 | binaryPath(); 17 | 18 | if ($binaryPath !== null) { 19 | return $binaryPath; 20 | } 21 | 22 | if ($this->confirm('Unable to locate RoadRunner binary. Should we download the binary for your operating system?', true)) { 23 | return $roadRunnerBinaryHelper->download(true); 24 | } 25 | 26 | throw new RuntimeException('Unable to locate RoadRunner binary.'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Commands/Concerns/Stubs.php: -------------------------------------------------------------------------------- 1 | laravel->basePath(trim($stub, '/'))) 18 | ? $customPath 19 | : __DIR__.'/../stubs/'.$stub; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Commands/InstallCommand.php: -------------------------------------------------------------------------------- 1 | ensureBinariesAreIgnored(); 24 | 25 | $this->ensureRoadRunnerBinaryIsInstalled($roadRunnerBinaryHelper); 26 | 27 | return Command::SUCCESS; 28 | } 29 | 30 | protected function ensureBinariesAreIgnored(): void 31 | { 32 | if (File::exists(base_path('.gitignore'))) { 33 | collect(['rr', '.rr.yaml']) 34 | ->each(function (string $file): void { 35 | $contents = File::get(base_path('.gitignore')); 36 | if (! Str::contains($contents, $file.PHP_EOL)) { 37 | File::append( 38 | base_path('.gitignore'), 39 | $file.PHP_EOL 40 | ); 41 | } 42 | }); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Commands/InterceptorMakeCommand.php: -------------------------------------------------------------------------------- 1 | resolveStubPath('interceptor.stub'); 46 | } 47 | 48 | protected function getDefaultNamespace($rootNamespace): string 49 | { 50 | return sprintf('%s\\Temporal\\Interceptors', $rootNamespace); 51 | } 52 | 53 | protected function buildClass($name): string 54 | { 55 | $stub = parent::buildClass($name); 56 | 57 | $types = $this->getTypesInput(); 58 | 59 | $interfaces = Collection::make($types) 60 | ->map(fn (string $type) => match ($type) { 61 | 'workflow_client_calls' => WorkflowClientCallsInterceptor::class, 62 | 'workflow_inbound_calls' => WorkflowInboundCallsInterceptor::class, 63 | 'workflow_outbound_calls' => WorkflowOutboundCallsInterceptor::class, 64 | 'activity_inbound' => ActivityInboundInterceptor::class, 65 | 'grpc_client' => GRPCClientInterceptor::class, 66 | 'workflow_outbound_request' => WorkflowOutboundRequestInterceptor::class, 67 | default => null, 68 | }) 69 | ->filter(); 70 | 71 | $traits = Collection::make($types) 72 | ->map(fn (string $type) => match ($type) { 73 | 'workflow_client_calls' => WorkflowClientCallsInterceptorTrait::class, 74 | 'workflow_inbound_calls' => WorkflowInboundCallsInterceptorTrait::class, 75 | 'workflow_outbound_calls' => WorkflowOutboundCallsInterceptorTrait::class, 76 | 'activity_inbound' => ActivityInboundInterceptorTrait::class, 77 | 'workflow_outbound_request' => WorkflowOutboundRequestInterceptorTrait::class, 78 | default => null, 79 | }) 80 | ->filter(); 81 | 82 | $stub = str_replace( 83 | '{{ interfaces }}', 84 | $interfaces->map(fn (string $interface) => class_basename($interface))->implode(', '), 85 | $stub 86 | ); 87 | 88 | $stub = str_replace( 89 | '{{ traits }}', 90 | $traits->map(fn (string $trait) => sprintf('use %s;', class_basename($trait)))->implode(PHP_EOL.' '), 91 | $stub 92 | ); 93 | 94 | return str_replace( 95 | '{{ imports }}', 96 | $interfaces->merge($traits) 97 | ->map(fn (string $interface) => sprintf('use %s;', $interface)) 98 | ->implode(PHP_EOL), 99 | $stub 100 | ); 101 | } 102 | 103 | protected function getOptions(): array 104 | { 105 | return [ 106 | [ 107 | 'type', 108 | 't', 109 | InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 110 | 'Temporal interceptor type', 111 | null, 112 | self::INTERCEPTOR_TYPES, 113 | ], 114 | [ 115 | 'force', 116 | 'f', 117 | InputOption::VALUE_NONE, 118 | 'Create the Interceptor class even if the file already exists', 119 | ], 120 | ]; 121 | } 122 | 123 | protected function getTypesInput(): array 124 | { 125 | if ($this->typesInput !== null) { 126 | return $this->typesInput; 127 | } 128 | 129 | $types = array_filter( 130 | is_array($this->option('type')) ? $this->option('type') : [], 131 | fn (string $type) => in_array($type, self::INTERCEPTOR_TYPES, strict: true) 132 | ); 133 | 134 | if ($types !== []) { 135 | return $this->typesInput = $types; 136 | } 137 | 138 | $types = $this->choice( 139 | question: 'Select Temporal interceptor types to implement', 140 | choices: self::INTERCEPTOR_TYPES, 141 | multiple: true 142 | ); 143 | assert(is_array($types)); 144 | 145 | return $this->typesInput = $types; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/Commands/TestServerCommand.php: -------------------------------------------------------------------------------- 1 | temporalServer = match ($this->option('enable-time-skipping')) { 28 | true => TimeSkippingTemporalServer::create(), 29 | default => LocalTemporalServer::create(), 30 | }; 31 | 32 | if ($this->verbosity > OutputInterface::VERBOSITY_NORMAL) { 33 | $this->temporalServer->setDebugOutput(true); 34 | } 35 | 36 | $this->temporalServer->start((int) $this->option('port')); 37 | 38 | $this->writeServerRunningMessage(); 39 | 40 | do { 41 | sleep(1); 42 | } while ($this->temporalServer->isRunning()); 43 | 44 | return 0; 45 | } 46 | 47 | /** 48 | * Returns the list of signals to subscribe. 49 | * 50 | * @return int[] 51 | */ 52 | public function getSubscribedSignals(): array 53 | { 54 | return [SIGINT, SIGTERM]; 55 | } 56 | 57 | /** 58 | * Write the server start "message" to the console. 59 | */ 60 | protected function writeServerRunningMessage(): void 61 | { 62 | $this->components->info(sprintf('Temporal testing server running on port: %s', (int) $this->option('port'))); 63 | 64 | if ($this->temporalServer instanceof LocalTemporalServer) { 65 | $this->components->info(sprintf('Temporal ui available at: [http://127.0.0.1:%s]', (int) $this->option('port') + 1000)); 66 | } 67 | 68 | $this->comment(' Press Ctrl+C to stop the server'); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Commands/WorkCommand.php: -------------------------------------------------------------------------------- 1 | ensureRoadRunnerBinaryIsInstalled($roadRunnerBinaryHelper); 43 | 44 | $configVersion = $this->detectRoadrunnerConfigVersion($roadRunnerBinaryHelper); 45 | 46 | $this->writeServerStateFile($serverStateFile); 47 | 48 | $this->queue = ($this->argument('queue') ?: config('temporal.queue')) ?: WorkerFactory::DEFAULT_TASK_QUEUE; 49 | 50 | $clientKey = config('temporal.tls.client_key'); 51 | $clientCert = config('temporal.tls.client_cert'); 52 | $rootCa = config('temporal.tls.root_ca'); 53 | $serverName = config('temporal.tls.server_name'); 54 | 55 | $server = new Process([ 56 | $roadRunnerBinary, 57 | ...['-c', $this->configPath()], 58 | ...['-o', sprintf('version=%s', $configVersion)], 59 | ...['-o', sprintf('server.command=%s ./vendor/bin/roadrunner-temporal-worker', (new PhpExecutableFinder)->find())], 60 | ...['-o', sprintf('temporal.address=%s', config('temporal.address'))], 61 | ...['-o', sprintf('temporal.namespace=%s', config('temporal.namespace'))], 62 | ...(is_string($clientKey) && is_string($clientCert)) 63 | ? ['-o', sprintf('temporal.tls.key=%s', $clientKey), '-o', sprintf('temporal.tls.cert=%s', $clientCert), '-o', 'temporal.tls.client_auth_type=require_and_verify_client_cert'] 64 | : [], 65 | ...is_string($rootCa) ? ['-o', sprintf('temporal.tls.root_ca=%s', $rootCa)] : [], 66 | ...is_string($serverName) ? ['-o', sprintf('temporal.tls.server_name=%s', $serverName)] : [], 67 | ...$this->workerCount() > 0 ? ['-o', sprintf('temporal.activities.num_workers=%s', $this->workerCount())] : [], 68 | ...$this->maxJobs() > 0 ? ['-o', sprintf('temporal.activities.max_jobs=%s', $this->maxJobs())] : [], 69 | ...['-o', sprintf('rpc.listen=tcp://%s:%d', $this->rpcHost(), $this->rpcPort())], 70 | ...['-o', 'logs.mode=production'], 71 | ...['-o', $this->laravel->environment('local') ? 'logs.level=debug' : 'logs.level=warn'], 72 | ...['-o', 'logs.output=stdout'], 73 | ...['-o', 'logs.encoding=json'], 74 | 'serve', 75 | ], base_path(), [ 76 | 'APP_ENV' => $this->laravel->environment(), 77 | 'APP_BASE_PATH' => $this->laravel->basePath(), 78 | 'LARAVEL_TEMPORAL' => 1, 79 | 'TEMPORAL_QUEUE' => $this->queue, 80 | ]); 81 | 82 | $server->start(); 83 | 84 | $serverStateFile->writeProcessId($server->getPid()); 85 | 86 | return $this->runServer($server, $inspector); 87 | } 88 | 89 | /** 90 | * Returns the list of signals to subscribe. 91 | * 92 | * @return int[] 93 | */ 94 | public function getSubscribedSignals(): array 95 | { 96 | return [SIGINT, SIGTERM]; 97 | } 98 | 99 | /** 100 | * Write the server state file. 101 | */ 102 | protected function writeServerStateFile(ServerStateFile $serverStateFile): void 103 | { 104 | $serverStateFile->writeState([ 105 | 'appName' => config('app.name', 'Laravel'), 106 | 'rpcHost' => $this->rpcHost(), 107 | 'rpcPort' => $this->rpcPort(), 108 | 'workers' => $this->workerCount(), 109 | 'config' => config('temporal'), 110 | ]); 111 | } 112 | 113 | /** 114 | * Get the RPC host the server should be available on. 115 | */ 116 | protected function rpcHost(): string 117 | { 118 | if (! is_string($this->option('rpc-host'))) { 119 | return '127.0.0.1'; 120 | } 121 | 122 | return $this->option('rpc-host'); 123 | } 124 | 125 | /** 126 | * Get the RPC port the server should be available on. 127 | */ 128 | protected function rpcPort(): int 129 | { 130 | $rpcPort = $this->option('rpc-port'); 131 | 132 | return is_numeric($rpcPort) ? (int) $rpcPort : 6001; 133 | } 134 | 135 | /** 136 | * Get the number of workers that should be started. 137 | */ 138 | protected function workerCount(): int 139 | { 140 | if (is_numeric($this->option('workers'))) { 141 | return (int) $this->option('workers'); 142 | } 143 | 144 | return 0; 145 | } 146 | 147 | /** 148 | * Get the number of workers that should be started. 149 | */ 150 | protected function maxJobs(): int 151 | { 152 | if (is_numeric($this->option('max-jobs'))) { 153 | return (int) $this->option('max-jobs'); 154 | } 155 | 156 | return 0; 157 | } 158 | 159 | /** 160 | * Get the path to the RoadRunner configuration file. 161 | */ 162 | protected function configPath(): string 163 | { 164 | $path = $this->option('rr-config'); 165 | 166 | if (is_string($path)) { 167 | return \Safe\realpath($path); 168 | } 169 | 170 | \Safe\touch(base_path('.rr.yaml')); 171 | 172 | return base_path('.rr.yaml'); 173 | } 174 | 175 | /** 176 | * Run the given server process. 177 | */ 178 | protected function runServer(Process $server, ServerProcessInspector $inspector): ?int 179 | { 180 | while (! $server->isStarted()) { 181 | sleep(1); 182 | } 183 | 184 | $this->writeServerRunningMessage(); 185 | 186 | $watcher = $this->startServerWatcher(); 187 | 188 | while ($server->isRunning()) { 189 | $this->writeServerOutput($server); 190 | 191 | if ($watcher !== null) { 192 | if ($watcher->isRunning() && $watcher->getIncrementalOutput()) { 193 | $this->components->info('Application change detected. Restarting workers…'); 194 | 195 | $inspector->reloadServer(); 196 | } elseif ($watcher->isTerminated()) { 197 | $this->error( 198 | 'Watcher process has terminated. Please ensure Node and chokidar are installed.'.PHP_EOL. 199 | $watcher->getErrorOutput() 200 | ); 201 | 202 | return Command::FAILURE; 203 | } 204 | } 205 | 206 | usleep(500 * 1000); 207 | } 208 | 209 | $this->writeServerOutput($server); 210 | 211 | $exitCode = $server->getExitCode(); 212 | 213 | if ($exitCode === Command::FAILURE) { 214 | $this->components->error('The worker has crashed. Please, verify that the host can connect to the Temporal service.'); 215 | } 216 | 217 | return $exitCode; 218 | } 219 | 220 | /** 221 | * Start the watcher process for the server. 222 | */ 223 | protected function startServerWatcher(): ?Process 224 | { 225 | if (! $this->option('watch')) { 226 | return null; 227 | } 228 | 229 | /** @var string[] $paths */ 230 | $paths = config('temporal.watch', []); 231 | 232 | if ($paths === []) { 233 | throw new InvalidArgumentException('List of directories/files to watch not found. Please update your "config/temporal.php" configuration file.'); 234 | } 235 | 236 | return tap(new Process([ 237 | (new ExecutableFinder)->find('node'), 238 | 'file-watcher.cjs', 239 | \Safe\json_encode(collect($paths)->map(fn (string $path) => base_path($path)), JSON_THROW_ON_ERROR), 240 | ], \Safe\realpath(__DIR__.'/../../bin'), null, null, null))->start(); 241 | } 242 | 243 | /** 244 | * Write the server start "message" to the console. 245 | */ 246 | protected function writeServerRunningMessage(): void 247 | { 248 | $this->components->info( 249 | sprintf('Processing activities from the [%s] queue.', $this->queue) 250 | ); 251 | 252 | $this->components->warn('Press Ctrl+C to stop the worker'); 253 | } 254 | 255 | /** 256 | * Write the server process output to the console. 257 | */ 258 | protected function writeServerOutput(Process $server): void 259 | { 260 | Str::of($server->getIncrementalOutput()) 261 | ->explode("\n") 262 | ->filter() 263 | ->each(function ($output): void { 264 | try { 265 | $debug = \Safe\json_decode($output, true, 512, JSON_THROW_ON_ERROR); 266 | } catch (Exception) { 267 | return; 268 | } 269 | 270 | if (! is_array($debug)) { 271 | $this->info($output); 272 | 273 | return; 274 | } 275 | 276 | /** 277 | * @var array{ 278 | * level: string, 279 | * msg: string, 280 | * ts: float, 281 | * logger: string, 282 | * "workflow info"?: array{ 283 | * WorkflowType: array{Name:string}, 284 | * TaskQueueName: string, 285 | * Attempt: int, 286 | * WorkflowStartTime: string 287 | * } 288 | * } $debug 289 | */ 290 | 291 | // $level = trim($debug['level']); 292 | $logger = trim($debug['logger']); 293 | $message = trim($debug['msg']); 294 | 295 | if ($logger !== 'temporal') { 296 | return; 297 | } 298 | 299 | if ($message === 'workflow execute' && isset($debug['workflow info'])) { 300 | $this->workflowInfo($debug['workflow info']); 301 | } 302 | }); 303 | 304 | Str::of($server->getIncrementalErrorOutput()) 305 | ->explode("\n") 306 | ->filter() 307 | ->each(function ($output): void { 308 | if (! Str::contains($output, ['DEBUG', 'INFO', 'WARN'])) { 309 | $this->error($output); 310 | } 311 | }); 312 | } 313 | 314 | protected function detectRoadrunnerConfigVersion(RoadRunnerBinaryHelper $roadRunnerBinaryHelper): string 315 | { 316 | try { 317 | return $roadRunnerBinaryHelper->configVersion(); 318 | } catch (\Throwable) { 319 | $this->warn('Your RoadRunner binary version may be incompatible with laravel temporal.'); 320 | 321 | return '2.7'; 322 | } 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /src/Commands/WorkflowMakeCommand.php: -------------------------------------------------------------------------------- 1 | option('interface') => $this->resolveStubPath('workflow_interface.stub'), 25 | default => $this->resolveStubPath('workflow.stub'), 26 | }; 27 | } 28 | 29 | protected function getDefaultNamespace($rootNamespace): string 30 | { 31 | $rootNamespace = match (true) { 32 | is_dir($this->laravel->path('Temporal/Workflows')) => $rootNamespace.'\\Temporal', 33 | ! is_dir($this->laravel->path('Workflows')) => $rootNamespace.'\\Temporal', 34 | default => $rootNamespace 35 | }; 36 | 37 | if ($this->option('scoped')) { 38 | $namespace = Str::of($this->getNameInput()) 39 | ->when($this->option('interface'), fn ($name) => $name->replaceLast('Interface', '')) 40 | ->whenEndsWith('Workflow', fn ($name) => $name->replaceLast('Workflow', '')); 41 | 42 | return sprintf('%s\\Workflows\\%s', $rootNamespace, $namespace); 43 | } 44 | 45 | return sprintf('%s\\Workflows', $rootNamespace); 46 | } 47 | 48 | protected function getNameInput(): string 49 | { 50 | return Str::of(parent::getNameInput()) 51 | ->whenEndsWith('Interface', fn ($name) => $name->replaceLast('Interface', '')) 52 | ->when($this->option('interface'), fn ($name) => $name->append('Interface')); 53 | } 54 | 55 | protected function getOptions(): array 56 | { 57 | return [ 58 | ['interface', 'i', InputOption::VALUE_NONE, 'Create an interface for the workflow instead of a class'], 59 | ['scoped', 's', InputOption::VALUE_NONE, 'Create the workflow inside a scoped directory'], 60 | ['force', 'f', InputOption::VALUE_NONE, 'Create the Interceptor class even if the file already exists'], 61 | ]; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Commands/stubs/activity.stub: -------------------------------------------------------------------------------- 1 | create(\Safe\json_encode($value->toTemporalPayload(), self::JSON_FLAGS)); 22 | } 23 | 24 | if ($value instanceof \BackedEnum) { 25 | return $this->create(\Safe\json_encode($value->value, self::JSON_FLAGS)); 26 | } 27 | 28 | if (class_exists(Data::class) && $value instanceof Data) { 29 | return $this->create($value->toJson(self::JSON_FLAGS)); 30 | } 31 | 32 | if ($value instanceof Model) { 33 | return $this->create(\Safe\json_encode(TemporalEloquentSerializer::toPayload($value), self::JSON_FLAGS)); 34 | } 35 | 36 | return parent::toPayload($value); 37 | } 38 | 39 | public function fromPayload(Payload $payload, Type $type) 40 | { 41 | if (! $type->isClass()) { 42 | return parent::fromPayload($payload, $type); 43 | } 44 | 45 | $typeName = $type->getName(); 46 | 47 | if (! class_exists($typeName)) { 48 | return parent::fromPayload($payload, $type); 49 | } 50 | 51 | try { 52 | $data = \Safe\json_decode($payload->getData(), true, 512, self::JSON_FLAGS); 53 | } catch (Throwable $throwable) { 54 | throw new DataConverterException($throwable->getMessage(), $throwable->getCode(), $throwable); 55 | } 56 | 57 | $reflection = new ReflectionClass($typeName); 58 | $class = $reflection->getName(); 59 | 60 | if ($reflection->implementsInterface(TemporalSerializable::class)) { 61 | /** @var class-string $class */ 62 | return $class::fromTemporalPayload($data); 63 | } 64 | 65 | if ($reflection->isEnum()) { 66 | /** @var class-string<\BackedEnum> $class */ 67 | return $class::from($data); 68 | } 69 | 70 | if ($reflection->isSubclassOf(Data::class)) { 71 | /** @var class-string $class */ 72 | return $class::from($data); 73 | } 74 | 75 | if ($reflection->isSubclassOf(Model::class)) { 76 | /** @var class-string $class */ 77 | return TemporalEloquentSerializer::fromPayload($class, $data); 78 | } 79 | 80 | return parent::fromPayload($payload, $type); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Exceptions/TemporalSerializerException.php: -------------------------------------------------------------------------------- 1 | useLocalCache(); 48 | } 49 | 50 | return $instance; 51 | } 52 | 53 | protected static function temporalTestingEnvironmentIsConfigured(): bool 54 | { 55 | $temporalTestingEnvironment = $GLOBALS['_temporal_environment'] ?? null; 56 | 57 | return $temporalTestingEnvironment instanceof TemporalTestingEnvironment; 58 | } 59 | 60 | public static function initFakeWorker(): void 61 | { 62 | if (static::$app->environment() === 'production') { 63 | return; 64 | } 65 | 66 | if (! isset($_SERVER['LARAVEL_TEMPORAL']) || ! isset($_SERVER['TEMPORAL_TESTING_ENV'])) { 67 | throw new \RuntimeException('This method can be called only from temporal test worker'); 68 | } 69 | 70 | if (isset($_SERVER['TEMPORAL_TESTING_CONFIG'])) { 71 | config()->set(\Safe\json_decode((string) $_SERVER['TEMPORAL_TESTING_CONFIG'], true) ?? []); 72 | DB::purge(); 73 | } 74 | 75 | if (isset($_SERVER['TEMPORAL_TESTING_REGISTRY'])) { 76 | $registryState = \Safe\json_decode((string) $_SERVER['TEMPORAL_TESTING_REGISTRY'], true) ?? []; 77 | 78 | static::$app->bind(TemporalRegistry::class, fn () => (new TemporalRegistry) 79 | ->registerWorkflows(...Arr::get($registryState, 'workflows', [])) 80 | ->registerActivities(...Arr::get($registryState, 'activities', [])) 81 | ); 82 | } 83 | 84 | static::swap((new TemporalFake(static::$app))); 85 | } 86 | 87 | public static function getTemporalContext(): Workflow\ScopedContextInterface 88 | { 89 | $instance = static::getFacadeRoot(); 90 | 91 | if (is_object($instance) && method_exists($instance, 'getTemporalContext')) { 92 | return $instance->getTemporalContext(); 93 | } 94 | 95 | return Workflow::getCurrentContext(); 96 | } 97 | 98 | protected static function getFacadeAccessor(): string 99 | { 100 | return \Keepsuit\LaravelTemporal\Contracts\Temporal::class; 101 | } 102 | 103 | public static function registry(): TemporalRegistry 104 | { 105 | return static::$app->make(TemporalRegistry::class); 106 | } 107 | 108 | public static function workflowClient(): WorkflowClientInterface 109 | { 110 | return static::$app->make(WorkflowClientInterface::class); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Integrations/Eloquent/TemporalEloquentSerialize.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | protected static array $modelsRelationsMap = []; 21 | 22 | public static function toPayload(Model $model): array 23 | { 24 | $relations = Collection::make($model->getRelations()) 25 | ->mapWithKeys(function (EloquentCollection|Model $value, string $key): array { 26 | return [ 27 | $key => match (true) { 28 | $value instanceof TemporalSerializable => $value->toTemporalPayload(), 29 | $value instanceof Model => static::toPayload($value), 30 | $value instanceof EloquentCollection => $value->map(fn (Model $related) => match (true) { 31 | $related instanceof TemporalSerializable => $related->toTemporalPayload(), 32 | default => static::toPayload($related), 33 | }), 34 | }, 35 | ]; 36 | }); 37 | 38 | return Collection::make($model->attributesToArray()) 39 | ->merge($relations) 40 | ->mapWithKeys(fn (mixed $value, string $key) => [static::mapAttributeKeyToTemporal($key) => $value]) 41 | ->when(static::shouldIncludeMetadataFields(), fn (Collection $collection) => $collection 42 | ->put('__exists', $model->exists) 43 | ->put('__dirty', $model->isDirty()) 44 | ) 45 | ->all(); 46 | } 47 | 48 | /** 49 | * @param class-string $className 50 | */ 51 | public static function fromPayload(string $className, array $payload): Model 52 | { 53 | $model = new $className; 54 | 55 | $relations = static::getModelRelationMethods($model); 56 | 57 | /** @var Collection $attributes */ 58 | $attributes = Collection::make($payload) 59 | ->mapWithKeys(fn (mixed $value, string $key) => [static::mapAttributeKeyFromTemporal($key) => $value]); 60 | 61 | /** @var bool $exists */ 62 | $exists = $attributes->get('__exists', $attributes->get($model->getKeyName()) !== null); 63 | 64 | /** @var bool $dirty */ 65 | $dirty = $attributes->get('__dirty', true); 66 | 67 | $instance = $model->newInstance([], $exists); 68 | 69 | $instance->forceFill($attributes->except($relations)->except(['__exists', '__dirty'])->all()); 70 | 71 | if (! $dirty) { 72 | $instance->syncOriginal(); 73 | } 74 | 75 | foreach ($relations as $relationName) { 76 | if (! $attributes->has($relationName)) { 77 | continue; 78 | } 79 | 80 | $relation = $model->{$relationName}(); 81 | 82 | if (! ($relation instanceof Relation)) { 83 | continue; 84 | } 85 | 86 | $relatedModel = $relation->getRelated(); 87 | 88 | if ($relation instanceof BelongsTo || $relation instanceof HasOne || $relation instanceof MorphOne) { 89 | $instance->setRelation( 90 | $relationName, 91 | static::fromPayload($relatedModel::class, $attributes->get($relationName)) 92 | ); 93 | 94 | continue; 95 | } 96 | 97 | $instance->setRelation($relationName, $relatedModel->newCollection( 98 | Collection::make($attributes->get($relationName) ?? []) 99 | ->map(fn (array $data) => static::fromPayload($relatedModel::class, $data)) 100 | ->filter() 101 | ->all() 102 | )); 103 | } 104 | 105 | return $instance; 106 | } 107 | 108 | protected static function mapAttributeKeyToTemporal(string $attribute): string 109 | { 110 | return match (config('temporal.integrations.eloquent.serialize_attribute_case')) { 111 | 'snake' => Str::snake($attribute), 112 | 'camel' => Str::camel($attribute), 113 | default => $attribute, 114 | }; 115 | } 116 | 117 | protected static function mapAttributeKeyFromTemporal(string $attribute): string 118 | { 119 | return match (config('temporal.integrations.eloquent.deserialize_attribute_case')) { 120 | 'snake' => Str::snake($attribute), 121 | 'camel' => Str::camel($attribute), 122 | default => $attribute, 123 | }; 124 | } 125 | 126 | protected static function shouldIncludeMetadataFields(): bool 127 | { 128 | return config('temporal.integrations.eloquent.include_metadata_field', false); 129 | } 130 | 131 | protected static function getModelRelationMethods(Model $model): array 132 | { 133 | if (isset(static::$modelsRelationsMap[$model::class])) { 134 | return static::$modelsRelationsMap[$model::class]; 135 | } 136 | 137 | $reflectionClass = new \ReflectionClass($model); 138 | 139 | $relations = collect($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC)) 140 | ->filter(function (\ReflectionMethod $reflectionMethod): bool { 141 | $returnType = $reflectionMethod->getReturnType(); 142 | 143 | return $returnType instanceof \ReflectionNamedType 144 | && is_subclass_of($returnType->getName(), Relation::class); 145 | }) 146 | ->map(fn (\ReflectionMethod $reflectionMethod) => $reflectionMethod->getName()) 147 | ->values() 148 | ->all(); 149 | 150 | return static::$modelsRelationsMap[$model::class] = $relations; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/Integrations/LaravelData/TemporalSerializableCast.php: -------------------------------------------------------------------------------- 1 | castValue($property->type->type->findAcceptedTypeForBaseType(TemporalSerializable::class), $value); 20 | } 21 | 22 | public function castIterableItem(DataProperty $property, mixed $value, array $properties, CreationContext $context): TemporalSerializable|Uncastable 23 | { 24 | return $this->castValue($property->type->iterableItemType, $value); 25 | } 26 | 27 | public function transform(DataProperty $property, mixed $value, TransformationContext $context): mixed 28 | { 29 | if ($value instanceof TemporalSerializable) { 30 | return $value->toTemporalPayload(); 31 | } 32 | 33 | return $value; 34 | } 35 | 36 | /** 37 | * @throws TemporalSerializerException 38 | */ 39 | protected function castValue(string $className, mixed $value): TemporalSerializable|Uncastable 40 | { 41 | if (! class_exists($className)) { 42 | return Uncastable::create(); 43 | } 44 | 45 | if (! is_array($value)) { 46 | return Uncastable::create(); 47 | } 48 | 49 | /** @var class-string $className */ 50 | return $className::fromTemporalPayload($value); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Integrations/LaravelData/TemporalSerializableTransformer.php: -------------------------------------------------------------------------------- 1 | withFreshApplication(fn () => $next($input)); 23 | } 24 | 25 | public function execute(WorkflowInput $input, callable $next): void 26 | { 27 | $this->withFreshApplication(fn () => $next($input)); 28 | } 29 | 30 | protected function withFreshApplication(\Closure $closure): mixed 31 | { 32 | $sandbox = CurrentApplication::createSandbox(); 33 | 34 | try { 35 | $response = $closure(); 36 | 37 | $sandbox->terminate(); 38 | 39 | return $response; 40 | } catch (Throwable $throwable) { 41 | $sandbox->make(ExceptionHandler::class)->report($throwable); 42 | 43 | throw $throwable; 44 | } finally { 45 | $sandbox->flush(); 46 | 47 | unset($sandbox); 48 | 49 | CurrentApplication::reset(); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/LaravelTemporalServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-temporal') 41 | ->hasConfigFile() 42 | ->hasCommands([ 43 | WorkCommand::class, 44 | InstallCommand::class, 45 | TestServerCommand::class, 46 | WorkflowMakeCommand::class, 47 | ActivityMakeCommand::class, 48 | InterceptorMakeCommand::class, 49 | ]); 50 | } 51 | 52 | public function packageRegistered(): void 53 | { 54 | $this->setupTestingEnvironment(); 55 | 56 | $this->app->bind(Contracts\Temporal::class, Temporal::class); 57 | 58 | $this->app->scoped(TemporalRegistry::class, $this->initTemporalRegistry(...)); 59 | 60 | $this->app->bind(ServerStateFile::class, fn (Application $app) => new ServerStateFile( 61 | $app['config']->get('temporal.state_file', storage_path('logs/temporal-worker-state.json')) 62 | )); 63 | 64 | $this->app->scoped(ServiceClientInterface::class, function (Application $app): ServiceClientInterface { 65 | $address = config('temporal.address'); 66 | 67 | $clientKey = config('temporal.tls.client_key'); 68 | $clientCert = config('temporal.tls.client_cert'); 69 | $rootCa = config('temporal.tls.root_ca'); 70 | $serverName = config('temporal.tls.server_name'); 71 | 72 | if (is_string($clientKey) && $clientKey !== '' && is_string($clientCert) && $clientCert !== '') { 73 | return ServiceClient::createSSL( 74 | $address, 75 | $rootCa, 76 | $clientKey, 77 | $clientCert, 78 | $serverName 79 | ); 80 | } else { 81 | return ServiceClient::create($address); 82 | } 83 | }); 84 | 85 | $this->app->bind(DataConverterInterface::class, fn (Application $app) => new DataConverter( 86 | new NullConverter, 87 | new BinaryConverter, 88 | new ProtoJsonConverter, 89 | new ProtoConverter, 90 | new LaravelPayloadConverter 91 | )); 92 | 93 | $this->app->scoped(WorkflowClientInterface::class, fn (Application $app) => WorkflowClient::create( 94 | serviceClient: $app->make(ServiceClientInterface::class), 95 | options: (new ClientOptions)->withNamespace(config('temporal.namespace')), 96 | converter: $app->make(DataConverterInterface::class), 97 | interceptorProvider: new SimplePipelineProvider(array_map( 98 | fn (string $className) => $app->make($className), 99 | config('temporal.interceptors', []) 100 | )) 101 | )); 102 | } 103 | 104 | protected function initTemporalRegistry(Application $app): TemporalRegistry 105 | { 106 | $workflowPaths = [ 107 | $app->path('Workflows'), 108 | $app->path('Temporal/Workflows'), 109 | ]; 110 | 111 | $activityPaths = [ 112 | $this->app->path('Workflows'), 113 | $this->app->path('Activities'), 114 | $this->app->path('Temporal/Activities'), 115 | $this->app->path('Temporal/Workflows'), 116 | ]; 117 | 118 | $registry = new TemporalRegistry; 119 | 120 | foreach ($workflowPaths as $workflowPath) { 121 | $registry->registerWorkflows(...DiscoverWorkflows::within($workflowPath)); 122 | } 123 | 124 | $registry->registerWorkflows(...$app['config']->get('temporal.workflows', [])); 125 | 126 | foreach ($activityPaths as $activityPath) { 127 | $registry->registerActivities(...DiscoverActivities::within($activityPath)); 128 | } 129 | 130 | $registry->registerActivities(...$app['config']->get('temporal.activities', [])); 131 | 132 | return $registry; 133 | } 134 | 135 | protected function setupTestingEnvironment(): void 136 | { 137 | if (! $this->app->environment('testing')) { 138 | return; 139 | } 140 | 141 | if (ParallelTesting::token() !== false) { 142 | $rpcPort = (int) config('temporal.rpc_port', 6001); 143 | config()->set('temporal.rpc_port', $rpcPort + (int) ParallelTesting::token()); 144 | 145 | if (config('temporal.testing.server', true)) { 146 | [$host, $port] = Str::of(config('temporal.address'))->explode(':', 2)->all(); 147 | config()->set('temporal.address', sprintf('%s:%s', $host, (int) $port + (int) ParallelTesting::token())); 148 | } else { 149 | config()->set('temporal.namespace', sprintf('%s-%s', config('temporal.namespace'), ParallelTesting::token())); 150 | } 151 | } 152 | 153 | $this->app->singleton(TemporalMocker::class, fn (Application $app) => new TemporalMocker( 154 | cache: TemporalMockerCache::create() 155 | )); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/PHPStan/TemporalActivityProxyExtension.php: -------------------------------------------------------------------------------- 1 | getName() !== ActivityProxy::class) { 22 | return false; 23 | } 24 | 25 | $activeTemplateTypeMap = $classReflection->getActiveTemplateTypeMap(); 26 | 27 | if ($activeTemplateTypeMap->count() !== 1) { 28 | return false; 29 | } 30 | 31 | $objectType = $activeTemplateTypeMap->getType('T'); 32 | 33 | if ($objectType->isObject()->no()) { 34 | return false; 35 | } 36 | 37 | if ($objectType->hasMethod($methodName)->no()) { 38 | return false; 39 | } 40 | 41 | $methodReflection = $objectType->getMethod($methodName, new OutOfClassScope); 42 | 43 | return $methodReflection->isPublic(); 44 | } 45 | 46 | public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection 47 | { 48 | $activeTemplateTypeMap = $classReflection->getActiveTemplateTypeMap(); 49 | 50 | $objectType = $activeTemplateTypeMap->getType('T'); 51 | 52 | $methodReflection = $objectType->getMethod($methodName, new OutOfClassScope); 53 | 54 | $methodReturnType = $methodReflection->getVariants()[0]->getReturnType(); 55 | 56 | $returnType = new GenericObjectType(CompletableResultInterface::class, [$methodReturnType]); 57 | 58 | return new class($classReflection, $methodName, $methodReflection, $returnType) implements MethodReflection 59 | { 60 | public function __construct( 61 | protected ClassReflection $classReflection, 62 | protected string $methodName, 63 | protected MethodReflection $methodReflection, 64 | protected Type $returnType 65 | ) {} 66 | 67 | public function isStatic(): bool 68 | { 69 | return false; 70 | } 71 | 72 | public function isPrivate(): bool 73 | { 74 | return false; 75 | } 76 | 77 | public function isPublic(): bool 78 | { 79 | return true; 80 | } 81 | 82 | public function getDocComment(): ?string 83 | { 84 | return null; 85 | } 86 | 87 | public function getName(): string 88 | { 89 | return $this->methodName; 90 | } 91 | 92 | public function getPrototype(): ClassMemberReflection 93 | { 94 | return $this; 95 | } 96 | 97 | public function getVariants(): array 98 | { 99 | $parameterAcceptor = $this->methodReflection->getVariants()[0]; 100 | 101 | return [ 102 | new FunctionVariant( 103 | $parameterAcceptor->getTemplateTypeMap(), 104 | $parameterAcceptor->getResolvedTemplateTypeMap(), 105 | $parameterAcceptor->getParameters(), 106 | $parameterAcceptor->isVariadic(), 107 | $this->returnType 108 | ), 109 | ]; 110 | } 111 | 112 | public function isDeprecated(): TrinaryLogic 113 | { 114 | return TrinaryLogic::createNo(); 115 | } 116 | 117 | public function getDeprecatedDescription(): ?string 118 | { 119 | return null; 120 | } 121 | 122 | public function isFinal(): TrinaryLogic 123 | { 124 | return TrinaryLogic::createNo(); 125 | } 126 | 127 | public function isInternal(): TrinaryLogic 128 | { 129 | return TrinaryLogic::createNo(); 130 | } 131 | 132 | public function getThrowType(): ?Type 133 | { 134 | return null; 135 | } 136 | 137 | public function hasSideEffects(): TrinaryLogic 138 | { 139 | return TrinaryLogic::createMaybe(); 140 | } 141 | 142 | public function getDeclaringClass(): ClassReflection 143 | { 144 | return $this->classReflection; 145 | } 146 | }; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/PHPStan/TemporalChildWorkflowProxyExtension.php: -------------------------------------------------------------------------------- 1 | getName() !== ChildWorkflowProxy::class) { 33 | return false; 34 | } 35 | 36 | $activeTemplateTypeMap = $classReflection->getActiveTemplateTypeMap(); 37 | 38 | if ($activeTemplateTypeMap->count() !== 1) { 39 | return false; 40 | } 41 | 42 | $objectType = $activeTemplateTypeMap->getType('T'); 43 | 44 | if ($objectType->isObject()->no()) { 45 | return false; 46 | } 47 | 48 | if (! $objectType->hasMethod($methodName)->yes()) { 49 | return false; 50 | } 51 | 52 | $methodReflection = $objectType->getMethod($methodName, new OutOfClassScope); 53 | 54 | if (! $methodReflection->isPublic()) { 55 | return false; 56 | } 57 | 58 | // @phpstan-ignore-next-line 59 | $objectClassReflection = new \ReflectionClass($objectType->getClassName()); 60 | $objectMethodReflection = $objectClassReflection->getMethod($methodName); 61 | $objectMethodAttributes = $objectMethodReflection->getAttributes(WorkflowMethod::class); 62 | 63 | return $objectMethodAttributes !== []; 64 | } 65 | 66 | public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection 67 | { 68 | $activeTemplateTypeMap = $classReflection->getActiveTemplateTypeMap(); 69 | 70 | $objectType = $activeTemplateTypeMap->getType('T'); 71 | assert($objectType->isObject()->yes()); 72 | 73 | $methodReflection = $objectType->getMethod($methodName, new OutOfClassScope); 74 | 75 | // @phpstan-ignore-next-line 76 | $objectClassReflection = new \ReflectionClass($objectType->getClassName()); 77 | $objectMethodReflection = $objectClassReflection->getMethod($methodName); 78 | $objectMethodAttributes = $objectMethodReflection->getAttributes(ReturnType::class); 79 | $objectMethodReturnType = $objectMethodAttributes === [] ? \Temporal\DataConverter\Type::TYPE_VOID : $objectMethodAttributes[0]->getArguments()[0]; 80 | assert(is_string($objectMethodReturnType)); 81 | $objectMethodReturnTypeNullable = $objectMethodAttributes === [] ? false : ($objectMethodAttributes[0]->getArguments()[1] ?? false); 82 | assert(is_bool($objectMethodReturnTypeNullable)); 83 | 84 | $returnType = match (true) { 85 | $objectMethodReturnType === \Temporal\DataConverter\Type::TYPE_VOID => new VoidType, 86 | $objectMethodReturnType === \Temporal\DataConverter\Type::TYPE_BOOL => new BooleanType, 87 | $objectMethodReturnType === \Temporal\DataConverter\Type::TYPE_STRING => new StringType, 88 | $objectMethodReturnType === \Temporal\DataConverter\Type::TYPE_INT => new IntegerType, 89 | $objectMethodReturnType === \Temporal\DataConverter\Type::TYPE_FLOAT => new FloatType, 90 | class_exists($objectMethodReturnType) => new ObjectType($objectMethodReturnType), 91 | default => new MixedType, 92 | }; 93 | 94 | $returnType = $objectMethodReturnTypeNullable 95 | ? new UnionType([$returnType, new NullType]) 96 | : $returnType; 97 | 98 | $returnType = new GenericObjectType(CompletableResultInterface::class, [$returnType]); 99 | 100 | return new class($classReflection, $methodName, $methodReflection, $returnType) implements MethodReflection 101 | { 102 | public function __construct( 103 | protected ClassReflection $classReflection, 104 | protected string $methodName, 105 | protected MethodReflection $methodReflection, 106 | protected Type $returnType 107 | ) {} 108 | 109 | public function isStatic(): bool 110 | { 111 | return false; 112 | } 113 | 114 | public function isPrivate(): bool 115 | { 116 | return false; 117 | } 118 | 119 | public function isPublic(): bool 120 | { 121 | return true; 122 | } 123 | 124 | public function getDocComment(): ?string 125 | { 126 | return null; 127 | } 128 | 129 | public function getName(): string 130 | { 131 | return $this->methodName; 132 | } 133 | 134 | public function getPrototype(): ClassMemberReflection 135 | { 136 | return $this; 137 | } 138 | 139 | public function getVariants(): array 140 | { 141 | $parameterAcceptor = $this->methodReflection->getVariants()[0]; 142 | 143 | return [ 144 | new FunctionVariant( 145 | $parameterAcceptor->getTemplateTypeMap(), 146 | $parameterAcceptor->getResolvedTemplateTypeMap(), 147 | $parameterAcceptor->getParameters(), 148 | $parameterAcceptor->isVariadic(), 149 | $this->returnType 150 | ), 151 | ]; 152 | } 153 | 154 | public function isDeprecated(): TrinaryLogic 155 | { 156 | return TrinaryLogic::createNo(); 157 | } 158 | 159 | public function getDeprecatedDescription(): ?string 160 | { 161 | return null; 162 | } 163 | 164 | public function isFinal(): TrinaryLogic 165 | { 166 | return TrinaryLogic::createNo(); 167 | } 168 | 169 | public function isInternal(): TrinaryLogic 170 | { 171 | return TrinaryLogic::createNo(); 172 | } 173 | 174 | public function getThrowType(): ?Type 175 | { 176 | return null; 177 | } 178 | 179 | public function hasSideEffects(): TrinaryLogic 180 | { 181 | return TrinaryLogic::createMaybe(); 182 | } 183 | 184 | public function getDeclaringClass(): ClassReflection 185 | { 186 | return $this->classReflection; 187 | } 188 | }; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/PHPStan/TemporalWorkflowClientInterfaceExtension.php: -------------------------------------------------------------------------------- 1 | getName()) { 24 | 'newWorkflowStub', 'newRunningWorkflowStub' => true, 25 | default => false, 26 | }; 27 | } 28 | 29 | public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type 30 | { 31 | $className = $methodCall->getArgs()[0]->value; 32 | $classNameType = $scope->getType($className); 33 | 34 | return new GenericObjectType(WorkflowProxy::class, [$classNameType->getClassStringObjectType()]); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/PHPStan/TemporalWorkflowContextInterfaceExtension.php: -------------------------------------------------------------------------------- 1 | getName()) { 25 | 'newActivityStub', 'newChildWorkflowStub' => true, 26 | default => false, 27 | }; 28 | } 29 | 30 | public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type 31 | { 32 | $className = $methodCall->getArgs()[0]->value; 33 | $classNameType = $scope->getType($className); 34 | 35 | $isClassString = method_exists($classNameType, 'isClassStringType') 36 | ? $classNameType->isClassStringType()->yes() 37 | : $classNameType->isClassString()->yes(); 38 | 39 | if ($isClassString) { 40 | return match ($methodReflection->getName()) { 41 | 'newActivityStub' => new GenericObjectType(ActivityProxy::class, [$classNameType->getClassStringObjectType()]), 42 | 'newChildWorkflowStub' => new GenericObjectType(ChildWorkflowProxy::class, [$classNameType->getClassStringObjectType()]), 43 | default => null, 44 | }; 45 | } 46 | 47 | return null; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/PHPStan/TemporalWorkflowProxyExtension.php: -------------------------------------------------------------------------------- 1 | getName() !== WorkflowProxy::class) { 31 | return false; 32 | } 33 | 34 | $activeTemplateTypeMap = $classReflection->getActiveTemplateTypeMap(); 35 | 36 | if ($activeTemplateTypeMap->count() !== 1) { 37 | return false; 38 | } 39 | 40 | $objectType = $activeTemplateTypeMap->getType('T'); 41 | 42 | if ($objectType->isObject()->no()) { 43 | return false; 44 | } 45 | 46 | if (! $objectType->hasMethod($methodName)->yes()) { 47 | return false; 48 | } 49 | 50 | $methodReflection = $objectType->getMethod($methodName, new OutOfClassScope); 51 | 52 | if (! $methodReflection->isPublic()) { 53 | return false; 54 | } 55 | 56 | // @phpstan-ignore-next-line 57 | $objectClassReflection = new \ReflectionClass($objectType->getClassName()); 58 | $objectMethodReflection = $objectClassReflection->getMethod($methodName); 59 | $objectMethodAttributes = $objectMethodReflection->getAttributes(WorkflowMethod::class); 60 | 61 | return $objectMethodAttributes !== []; 62 | } 63 | 64 | public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection 65 | { 66 | $activeTemplateTypeMap = $classReflection->getActiveTemplateTypeMap(); 67 | 68 | $objectType = $activeTemplateTypeMap->getType('T'); 69 | assert($objectType->isObject()->yes()); 70 | 71 | $methodReflection = $objectType->getMethod($methodName, new OutOfClassScope); 72 | 73 | // @phpstan-ignore-next-line 74 | $objectClassReflection = new \ReflectionClass($objectType->getClassName()); 75 | $objectMethodReflection = $objectClassReflection->getMethod($methodName); 76 | $objectMethodAttributes = $objectMethodReflection->getAttributes(ReturnType::class); 77 | $objectMethodReturnType = $objectMethodAttributes === [] ? \Temporal\DataConverter\Type::TYPE_VOID : $objectMethodAttributes[0]->getArguments()[0]; 78 | assert(is_string($objectMethodReturnType)); 79 | $objectMethodReturnTypeNullable = $objectMethodAttributes === [] ? false : ($objectMethodAttributes[0]->getArguments()[1] ?? false); 80 | assert(is_bool($objectMethodReturnTypeNullable)); 81 | 82 | $returnType = match (true) { 83 | $objectMethodReturnType === \Temporal\DataConverter\Type::TYPE_VOID => new VoidType, 84 | $objectMethodReturnType === \Temporal\DataConverter\Type::TYPE_BOOL => new BooleanType, 85 | $objectMethodReturnType === \Temporal\DataConverter\Type::TYPE_STRING => new StringType, 86 | $objectMethodReturnType === \Temporal\DataConverter\Type::TYPE_INT => new IntegerType, 87 | $objectMethodReturnType === \Temporal\DataConverter\Type::TYPE_FLOAT => new FloatType, 88 | class_exists($objectMethodReturnType) => new ObjectType($objectMethodReturnType), 89 | default => new MixedType, 90 | }; 91 | 92 | $returnType = $objectMethodReturnTypeNullable 93 | ? new UnionType([$returnType, new NullType]) 94 | : $returnType; 95 | 96 | return new class($classReflection, $methodName, $methodReflection, $returnType) implements MethodReflection 97 | { 98 | public function __construct( 99 | protected ClassReflection $classReflection, 100 | protected string $methodName, 101 | protected MethodReflection $methodReflection, 102 | protected Type $returnType 103 | ) {} 104 | 105 | public function isStatic(): bool 106 | { 107 | return false; 108 | } 109 | 110 | public function isPrivate(): bool 111 | { 112 | return false; 113 | } 114 | 115 | public function isPublic(): bool 116 | { 117 | return true; 118 | } 119 | 120 | public function getDocComment(): ?string 121 | { 122 | return null; 123 | } 124 | 125 | public function getName(): string 126 | { 127 | return $this->methodName; 128 | } 129 | 130 | public function getPrototype(): ClassMemberReflection 131 | { 132 | return $this; 133 | } 134 | 135 | public function getVariants(): array 136 | { 137 | $parameterAcceptor = $this->methodReflection->getVariants()[0]; 138 | 139 | return [ 140 | new FunctionVariant( 141 | $parameterAcceptor->getTemplateTypeMap(), 142 | $parameterAcceptor->getResolvedTemplateTypeMap(), 143 | $parameterAcceptor->getParameters(), 144 | $parameterAcceptor->isVariadic(), 145 | $this->returnType 146 | ), 147 | ]; 148 | } 149 | 150 | public function isDeprecated(): TrinaryLogic 151 | { 152 | return TrinaryLogic::createNo(); 153 | } 154 | 155 | public function getDeprecatedDescription(): ?string 156 | { 157 | return null; 158 | } 159 | 160 | public function isFinal(): TrinaryLogic 161 | { 162 | return TrinaryLogic::createNo(); 163 | } 164 | 165 | public function isInternal(): TrinaryLogic 166 | { 167 | return TrinaryLogic::createNo(); 168 | } 169 | 170 | public function getThrowType(): ?Type 171 | { 172 | return null; 173 | } 174 | 175 | public function hasSideEffects(): TrinaryLogic 176 | { 177 | return TrinaryLogic::createMaybe(); 178 | } 179 | 180 | public function getDeclaringClass(): ClassReflection 181 | { 182 | return $this->classReflection; 183 | } 184 | }; 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/Support/ApplicationFactory.php: -------------------------------------------------------------------------------- 1 | $initialInstances 20 | */ 21 | public function createApplication(array $initialInstances = []): Application 22 | { 23 | $path = $this->basePath.'/bootstrap/app.php'; 24 | 25 | if (! file_exists($path)) { 26 | throw new RuntimeException(sprintf('Application bootstrap file not found [%s].', $path)); 27 | } 28 | 29 | return $this->warm($this->bootstrap(require $path, $initialInstances)); 30 | } 31 | 32 | /** 33 | * Bootstrap the given application. 34 | * 35 | * @param array $initialInstances 36 | */ 37 | public function bootstrap(Application $app, array $initialInstances = []): Application 38 | { 39 | foreach ($initialInstances as $key => $value) { 40 | $app->instance($key, $value); 41 | } 42 | 43 | $app->bootstrapWith($this->getBootstrappers($app)); 44 | 45 | $app->loadDeferredProviders(); 46 | 47 | return $app; 48 | } 49 | 50 | /** 51 | * Get the application's HTTP kernel bootstrappers. 52 | * 53 | * @return mixed[] 54 | */ 55 | protected function getBootstrappers(Application $app): array 56 | { 57 | $method = (new ReflectionObject( 58 | $kernel = $app->make(Kernel::class) 59 | ))->getMethod('bootstrappers'); 60 | 61 | $method->setAccessible(true); 62 | 63 | return $this->injectBootstrapperBefore( 64 | RegisterProviders::class, 65 | SetRequestForConsole::class, 66 | $method->invoke($kernel) 67 | ); 68 | } 69 | 70 | /** 71 | * Inject a given bootstrapper before another bootstrapper. 72 | * 73 | * @param mixed[] $bootstrappers 74 | * @return mixed[] 75 | */ 76 | protected function injectBootstrapperBefore(string $before, string $inject, array $bootstrappers): array 77 | { 78 | $injectIndex = array_search($before, $bootstrappers, true); 79 | 80 | if (is_int($injectIndex)) { 81 | array_splice($bootstrappers, $injectIndex, 0, [$inject]); 82 | } 83 | 84 | return $bootstrappers; 85 | } 86 | 87 | /** 88 | * Warm the application with pre-resolved, cached services that persist across requests. 89 | * 90 | * @param mixed[] $services 91 | */ 92 | public function warm(Application $app, array $services = []): Application 93 | { 94 | foreach ($services ?: $app->make('config')->get('temporal.warm', []) as $service) { 95 | if (! is_string($service)) { 96 | continue; 97 | } 98 | 99 | if (! $app->bound($service)) { 100 | continue; 101 | } 102 | 103 | $app->make($service); 104 | 105 | return $app; 106 | } 107 | 108 | return $app; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Support/CurrentApplication.php: -------------------------------------------------------------------------------- 1 | instance('app', $app); 40 | $app->instance(Container::class, $app); 41 | 42 | Container::setInstance($app); 43 | 44 | Facade::clearResolvedInstances(); 45 | Facade::setFacadeApplication($app); 46 | } 47 | 48 | protected static function ensureRootAppIsSet(): void 49 | { 50 | if (static::$root === null) { 51 | throw new RuntimeException('Root application not set'); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Support/DiscoverActivities.php: -------------------------------------------------------------------------------- 1 | $activities */ 23 | $activities = Collection::make(); 24 | 25 | $generator = new ClassMapGenerator; 26 | $generator->scanPaths($activitiesPath); 27 | 28 | foreach (array_keys($generator->getClassMap()->getMap()) as $class) { 29 | $activity = new \ReflectionClass($class); 30 | 31 | /** @var \ReflectionClass[] $interfaces */ 32 | $interfaces = array_merge( 33 | $activity->getInterfaces(), 34 | [$activity->getName() => $activity], 35 | ); 36 | 37 | foreach ($interfaces as $interface) { 38 | foreach ($interface->getAttributes() as $attribute) { 39 | if ($attribute->newInstance() instanceof ActivityInterface) { 40 | if (! $activity->isInterface() || ! $activities->has($interface->getName())) { 41 | $activities->put($interface->getName(), $activity->isInterface() ? null : $activity->getName()); 42 | } 43 | 44 | break 2; 45 | } 46 | } 47 | } 48 | } 49 | 50 | return $activities->map(fn ($value, $key) => $value ?? $key)->sort()->values()->all(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Support/DiscoverWorkflows.php: -------------------------------------------------------------------------------- 1 | $workflows */ 23 | $workflows = Collection::make(); 24 | 25 | $generator = new ClassMapGenerator; 26 | $generator->scanPaths($workflowPath); 27 | 28 | foreach (array_keys($generator->getClassMap()->getMap()) as $class) { 29 | $workflow = new \ReflectionClass($class); 30 | 31 | /** @var \ReflectionClass[] $interfaces */ 32 | $interfaces = array_merge( 33 | $workflow->getInterfaces(), 34 | [$workflow->getName() => $workflow] 35 | ); 36 | 37 | foreach ($interfaces as $interface) { 38 | if ($interface->getAttributes(WorkflowInterface::class) !== []) { 39 | if (! $workflow->isInterface() || ! $workflows->has($interface->getName())) { 40 | $workflows->put($interface->getName(), $workflow->isInterface() ? null : $workflow->getName()); 41 | } 42 | 43 | break; 44 | } 45 | } 46 | } 47 | 48 | return $workflows->map(fn ($value, $key) => $value ?? $key)->sort()->values()->all(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Support/PosixExtension.php: -------------------------------------------------------------------------------- 1 | binaryPath !== null) { 20 | return $this->binaryPath; 21 | } 22 | 23 | if (file_exists(base_path('rr'))) { 24 | return $this->binaryPath = base_path('rr'); 25 | } 26 | 27 | $roadRunnerBinary = (new ExecutableFinder)->find('rr', null, [base_path()]); 28 | 29 | if ($roadRunnerBinary === null) { 30 | return null; 31 | } 32 | 33 | if (Str::contains($roadRunnerBinary, 'vendor/bin/rr')) { 34 | return null; 35 | } 36 | 37 | return $this->binaryPath = $roadRunnerBinary; 38 | } 39 | 40 | public function configVersion(): string 41 | { 42 | $binaryPath = $this->binaryPath(); 43 | 44 | if ($binaryPath === null) { 45 | throw new \RuntimeException('RoadRunner binary not found.'); 46 | } 47 | 48 | $version = tap(new Process([$binaryPath, '--version'], base_path())) 49 | ->run() 50 | ->getOutput(); 51 | 52 | $version = explode(' ', (string) $version)[2]; 53 | 54 | if (version_compare($version, '2023.1', '>')) { 55 | return '3'; 56 | } 57 | 58 | if (version_compare($version, '2.0', '>')) { 59 | return '2.7'; 60 | } 61 | 62 | throw new \RuntimeException(sprintf('Your RoadRunner binary version (%s) is not compatible with laravel temporal.', $version)); 63 | } 64 | 65 | public function download(bool $force = false): string 66 | { 67 | if (! $force && $this->binaryPath() !== null) { 68 | return $this->binaryPath(); 69 | } 70 | 71 | $process = new Process(array_filter([ 72 | (new PhpExecutableFinder)->find(), 73 | './vendor/bin/rr', 74 | 'get-binary', 75 | '-n', 76 | '--ansi', 77 | ]), base_path(), null, null, null); 78 | 79 | $process->mustRun(); 80 | 81 | $this->binaryPath = base_path('rr'); 82 | 83 | \Safe\chmod($this->binaryPath, 0755); 84 | \Safe\touch(base_path('.rr.yaml')); 85 | 86 | return $this->binaryPath; 87 | } 88 | 89 | public function ensureConfigFileExists(): void 90 | { 91 | if (! file_exists(base_path('.rr.yaml'))) { 92 | \Safe\touch(base_path('.rr.yaml')); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Support/ServerProcessInspector.php: -------------------------------------------------------------------------------- 1 | $masterProcessId, 22 | ] = $this->serverStateFile->read(); 23 | 24 | return $masterProcessId && $this->posix->kill($masterProcessId, 0); 25 | } 26 | 27 | /** 28 | * Reload the RoadRunner workers. 29 | */ 30 | public function reloadServer(): void 31 | { 32 | [ 33 | 'state' => [ 34 | 'rpcHost' => $rpcHost, 35 | 'rpcPort' => $rpcPort, 36 | ], 37 | ] = $this->serverStateFile->read(); 38 | 39 | $process = new Process( 40 | command: [ 41 | $this->roadRunnerFinder->binaryPath(), 42 | 'reset', 43 | '-o', 44 | sprintf('rpc.listen=tcp://%s:%s', $rpcHost, $rpcPort), 45 | ], 46 | cwd: base_path() 47 | ); 48 | 49 | $process->start(); 50 | 51 | // $process->waitUntil(function ($type, $buffer): bool { 52 | // The type is ERR even when reload is success 53 | // 54 | // if ($type === Process::ERR) { 55 | // throw new RuntimeException('Cannot reload RoadRunner: '.$buffer); 56 | // } 57 | // 58 | // return true; 59 | // }); 60 | } 61 | 62 | /** 63 | * Stop the RoadRunner server. 64 | */ 65 | public function stopServer(): bool 66 | { 67 | [ 68 | 'masterProcessId' => $masterProcessId, 69 | ] = $this->serverStateFile->read(); 70 | 71 | return $this->posix->kill($masterProcessId, SIGTERM); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Support/ServerStateFile.php: -------------------------------------------------------------------------------- 1 | path) 20 | ? \Safe\json_decode(\Safe\file_get_contents($this->path), true, 512, JSON_THROW_ON_ERROR) 21 | : []; 22 | 23 | return [ 24 | 'masterProcessId' => $state['masterProcessId'] ?? null, 25 | 'state' => $state['state'] ?? [], 26 | ]; 27 | } 28 | 29 | /** 30 | * Write the given process ID to the server state file. 31 | */ 32 | public function writeProcessId(int $masterProcessId): void 33 | { 34 | if (! is_writable($this->path) && ! is_writable(dirname($this->path))) { 35 | throw new RuntimeException('Unable to write to process ID file.'); 36 | } 37 | 38 | \Safe\file_put_contents($this->path, \Safe\json_encode( 39 | [...$this->read(), 'masterProcessId' => $masterProcessId], 40 | JSON_PRETTY_PRINT 41 | )); 42 | } 43 | 44 | /** 45 | * Write the given state array to the server state file. 46 | * 47 | * @param array $newState 48 | */ 49 | public function writeState(array $newState): void 50 | { 51 | if (! is_writable($this->path) && ! is_writable(dirname($this->path))) { 52 | throw new RuntimeException('Unable to write to process ID file.'); 53 | } 54 | 55 | \Safe\file_put_contents($this->path, \Safe\json_encode( 56 | [...$this->read(), 'state' => $newState], 57 | JSON_PRETTY_PRINT 58 | )); 59 | } 60 | 61 | /** 62 | * Delete the process ID file. 63 | */ 64 | public function delete(): bool 65 | { 66 | try { 67 | \Safe\unlink($this->path); 68 | 69 | return true; 70 | } catch (FilesystemException) { 71 | return false; 72 | } 73 | } 74 | 75 | /** 76 | * Get the path to the process ID file. 77 | */ 78 | public function path(): string 79 | { 80 | return $this->path; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Temporal.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | class TemporalRegistry implements Arrayable 11 | { 12 | /** 13 | * @var array 14 | */ 15 | protected array $registeredWorkflows = []; 16 | 17 | /** 18 | * @var array 19 | */ 20 | protected array $registeredActivities = []; 21 | 22 | /** 23 | * @param class-string ...$workflowClasses 24 | */ 25 | public function registerWorkflows(string ...$workflowClasses): TemporalRegistry 26 | { 27 | array_push($this->registeredWorkflows, ...$workflowClasses); 28 | 29 | return $this; 30 | } 31 | 32 | /** 33 | * @param class-string ...$activityClasses 34 | */ 35 | public function registerActivities(string ...$activityClasses): TemporalRegistry 36 | { 37 | array_push($this->registeredActivities, ...$activityClasses); 38 | 39 | return $this; 40 | } 41 | 42 | /** 43 | * @return array 44 | */ 45 | public function workflows(): array 46 | { 47 | return array_unique($this->registeredWorkflows); 48 | } 49 | 50 | /** 51 | * @return array 52 | */ 53 | public function activities(): array 54 | { 55 | return array_unique($this->registeredActivities); 56 | } 57 | 58 | /** 59 | * @return array{ 60 | * workflows:array, 61 | * activities:array 62 | * } 63 | */ 64 | public function toArray(): array 65 | { 66 | return [ 67 | 'workflows' => $this->workflows(), 68 | 'activities' => $this->activities(), 69 | ]; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Testing/ActivityMock.php: -------------------------------------------------------------------------------- 1 | assertDispatchedTimes($callback); 19 | 20 | return; 21 | } 22 | 23 | Temporal::assertActivityDispatched($this->activityName, function (...$args) use ($callback) { 24 | $taskQueue = Arr::last($args); 25 | 26 | if ($this->taskQueue !== null && $this->taskQueue !== $taskQueue) { 27 | return false; 28 | } 29 | 30 | if ($callback !== null) { 31 | return $callback(...$args); 32 | } 33 | 34 | return true; 35 | }); 36 | } 37 | 38 | public function assertDispatchedTimes(int $times = 1, ?\Closure $callback = null): void 39 | { 40 | Temporal::assertActivityDispatchedTimes($this->activityName, $times, function (...$args) use ($callback) { 41 | $taskQueue = Arr::last($args); 42 | 43 | if ($this->taskQueue !== null && $this->taskQueue !== $taskQueue) { 44 | return false; 45 | } 46 | 47 | if ($callback !== null) { 48 | return $callback(...$args); 49 | } 50 | 51 | return true; 52 | }); 53 | } 54 | 55 | public function assertNotDispatched(?\Closure $callback = null): void 56 | { 57 | Temporal::assertActivityNotDispatched($this->activityName, function (...$args) use ($callback) { 58 | $taskQueue = Arr::last($args); 59 | 60 | if ($this->taskQueue !== null && $this->taskQueue !== $taskQueue) { 61 | return false; 62 | } 63 | 64 | if ($callback !== null) { 65 | return $callback(...$args); 66 | } 67 | 68 | return true; 69 | }); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Testing/ActivityMockBuilder.php: -------------------------------------------------------------------------------- 1 | taskQueue = $taskQueue; 16 | 17 | return $this; 18 | } 19 | 20 | public function andReturn(mixed $returnValue): ActivityMock 21 | { 22 | Temporal::mockActivities([ 23 | $this->activityName => $returnValue, 24 | ], $this->taskQueue); 25 | 26 | return new ActivityMock($this->activityName, $this->taskQueue); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Testing/Fakes/FakeActivityStub.php: -------------------------------------------------------------------------------- 1 | stub->getOptions(); 24 | } 25 | 26 | public function execute(string $name, array $args = [], Type|string|\ReflectionClass|\ReflectionType|null $returnType = null, bool $isLocalActivity = false): PromiseInterface 27 | { 28 | /** @var ActivityOptions|LocalActivityOptions $options */ 29 | $options = $this->getOptions(); 30 | 31 | $taskQueue = $options instanceof ActivityOptions ? $options->taskQueue : null; 32 | 33 | $mock = $this->getTemporalMocker()->getActivityResult($name, $taskQueue); 34 | 35 | if (! $mock instanceof \Closure) { 36 | return $this->stub->execute($name, $args, $returnType, $isLocalActivity); 37 | } 38 | 39 | $this->getTemporalMocker()->recordActivityDispatch($name, $taskQueue, $args); 40 | 41 | $this->result = $mock->__invoke(...$args); 42 | 43 | $request = new Promise(function (callable $resolve): void { 44 | $resolve($this->result); 45 | }); 46 | 47 | // @phpstan-ignore-next-line 48 | return EncodedValues::decodePromise($request); 49 | } 50 | 51 | protected function getTemporalMocker(): TemporalMocker 52 | { 53 | return app(TemporalMocker::class); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Testing/Fakes/FakeChildWorkflowStub.php: -------------------------------------------------------------------------------- 1 | stub->getExecution(); 25 | } 26 | 27 | public function getChildWorkflowType(): string 28 | { 29 | return $this->stub->getChildWorkflowType(); 30 | } 31 | 32 | public function getOptions(): ChildWorkflowOptions 33 | { 34 | return $this->stub->getOptions(); 35 | } 36 | 37 | public function execute(array $args = [], $returnType = null): PromiseInterface 38 | { 39 | // @phpstan-ignore-next-line 40 | return $this->start(...$args)->then(fn () => $this->getResult($returnType)); 41 | } 42 | 43 | public function start(...$args): PromiseInterface 44 | { 45 | $mock = $this->getTemporalMocker()->getWorkflowResult($this->stub->getChildWorkflowType(), $this->stub->getOptions()->taskQueue); 46 | 47 | $this->hasMock = $mock instanceof \Closure; 48 | 49 | if (! $this->hasMock) { 50 | return $this->stub->start(...$args); 51 | } 52 | 53 | $this->getTemporalMocker()->recordWorkflowDispatch($this->stub->getChildWorkflowType(), $this->stub->getOptions()->taskQueue, $args); 54 | 55 | $this->result = $mock->__invoke(...$args); 56 | 57 | $started = new Promise(function (callable $resolve): void { 58 | $resolve(new WorkflowExecution(Str::uuid(), Str::uuid())); 59 | }); 60 | 61 | // @phpstan-ignore-next-line 62 | return EncodedValues::decodePromise($started); 63 | } 64 | 65 | // @phpstan-ignore-next-line 66 | public function getResult($returnType = null): PromiseInterface 67 | { 68 | if (! $this->hasMock) { 69 | return $this->stub->getResult($returnType); 70 | } 71 | 72 | return new Promise(function (callable $resolve): void { 73 | $resolve($this->result); 74 | }); 75 | } 76 | 77 | public function signal(string $name, array $args = []): PromiseInterface 78 | { 79 | return $this->stub->signal($name, $args); 80 | } 81 | 82 | protected function getTemporalMocker(): TemporalMocker 83 | { 84 | return app(TemporalMocker::class); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Testing/Fakes/FakeScopeContext.php: -------------------------------------------------------------------------------- 1 | context->async($handler); 19 | } 20 | 21 | public function asyncDetached(callable $handler): CancellationScopeInterface 22 | { 23 | // @phpstan-ignore-next-line 24 | return $this->context->asyncDetached($handler); 25 | } 26 | 27 | public function registerUpdate(string $name, callable $handler, ?callable $validator): static 28 | { 29 | $this->context->registerUpdate($name, $handler, $validator); 30 | 31 | return $this; 32 | } 33 | 34 | public function allHandlersFinished(): bool 35 | { 36 | return $this->context->allHandlersFinished(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Testing/Fakes/FakeWorkflowClient.php: -------------------------------------------------------------------------------- 1 | getTemporalMocker()->getWorkflowResult($workflowStub->getWorkflowType(), $workflowStub->getOptions()->taskQueue); 23 | 24 | if (! ($workflowMock instanceof \Closure)) { 25 | return parent::start($workflow, ...$args); 26 | } 27 | 28 | $this->getTemporalMocker()->recordWorkflowDispatch($workflowStub->getWorkflowType(), $workflowStub->getOptions()->taskQueue, $args); 29 | 30 | $execution = new WorkflowExecution(Str::uuid(), Str::uuid()); 31 | 32 | $workflowStub->setExecution($execution); 33 | 34 | $result = $workflowMock->__invoke(...$args); 35 | 36 | return new FakeWorkflowRun($workflowStub, $result); 37 | } 38 | 39 | protected function getTemporalMocker(): TemporalMocker 40 | { 41 | return app(TemporalMocker::class); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Testing/Fakes/FakeWorkflowContext.php: -------------------------------------------------------------------------------- 1 | context->now(); 32 | } 33 | 34 | public function isReplaying(): bool 35 | { 36 | return $this->context->isReplaying(); 37 | } 38 | 39 | public function getInfo(): WorkflowInfo 40 | { 41 | return $this->context->getInfo(); 42 | } 43 | 44 | public function getInput(): ValuesInterface 45 | { 46 | return $this->context->getInput(); 47 | } 48 | 49 | public function getLastCompletionResult($type = null) 50 | { 51 | return $this->context->getLastCompletionResult($type); 52 | } 53 | 54 | public function registerQuery(string $queryType, callable $handler): WorkflowContextInterface 55 | { 56 | // @phpstan-ignore-next-line 57 | return $this->context->registerQuery($queryType, $handler); 58 | } 59 | 60 | public function registerSignal(string $queryType, callable $handler): WorkflowContextInterface 61 | { 62 | // @phpstan-ignore-next-line 63 | return $this->context->registerSignal($queryType, $handler); 64 | } 65 | 66 | public function request(RequestInterface $request, bool $cancellable = true, bool $waitResponse = true): PromiseInterface 67 | { 68 | return $this->context->request($request, $cancellable); 69 | } 70 | 71 | public function getVersion(string $changeId, int $minSupported, int $maxSupported): PromiseInterface 72 | { 73 | return $this->context->getVersion($changeId, $minSupported, $maxSupported); 74 | } 75 | 76 | public function sideEffect(callable $context): PromiseInterface 77 | { 78 | return $this->context->sideEffect($context); 79 | } 80 | 81 | public function complete(?array $result = null, ?\Throwable $failure = null): PromiseInterface 82 | { 83 | return $this->context->complete($result, $failure); 84 | } 85 | 86 | public function panic(?\Throwable $failure = null): PromiseInterface 87 | { 88 | return $this->context->panic($failure); 89 | } 90 | 91 | public function timer($interval): PromiseInterface 92 | { 93 | return $this->context->timer($interval); 94 | } 95 | 96 | public function continueAsNew(string $type, array $args = [], ?ContinueAsNewOptions $options = null): PromiseInterface 97 | { 98 | return $this->context->continueAsNew($type, $args, $options); 99 | } 100 | 101 | public function newContinueAsNewStub(string $class, ?ContinueAsNewOptions $options = null): object 102 | { 103 | return $this->context->newContinueAsNewStub($class, $options); 104 | } 105 | 106 | public function executeChildWorkflow(string $type, array $args = [], ?ChildWorkflowOptions $options = null, $returnType = null): PromiseInterface 107 | { 108 | return $this->context->executeChildWorkflow($type, $args, $options, $returnType); 109 | } 110 | 111 | public function newChildWorkflowStub(string $class, ?ChildWorkflowOptions $options = null): object 112 | { 113 | /** @var ChildWorkflowProxy $workflowProxy */ 114 | $workflowProxy = $this->context->newChildWorkflowStub($class, $options); 115 | 116 | $reflection = new \ReflectionClass($workflowProxy); 117 | $properties = collect($reflection->getProperties()) 118 | ->each(fn (\ReflectionProperty $property) => $property->setAccessible(true)) 119 | ->mapWithKeys(fn (\ReflectionProperty $property) => [$property->getName() => $property->getValue($workflowProxy)]); 120 | 121 | // @phpstan-ignore-next-line 122 | return new ChildWorkflowProxy( 123 | $properties->get('class'), 124 | $properties->get('workflow'), 125 | $properties->get('options'), 126 | $this 127 | ); 128 | } 129 | 130 | public function newUntypedChildWorkflowStub(string $type, ?ChildWorkflowOptions $options = null): ChildWorkflowStubInterface 131 | { 132 | return new FakeChildWorkflowStub($this->context->newUntypedChildWorkflowStub($type, $options)); 133 | } 134 | 135 | public function newExternalWorkflowStub(string $class, WorkflowExecution $execution): object 136 | { 137 | return $this->context->newExternalWorkflowStub($class, $execution); 138 | } 139 | 140 | public function newUntypedExternalWorkflowStub(WorkflowExecution $execution): ExternalWorkflowStubInterface 141 | { 142 | return $this->context->newUntypedExternalWorkflowStub($execution); 143 | } 144 | 145 | public function executeActivity( 146 | string $type, 147 | array $args = [], 148 | ?ActivityOptionsInterface $options = null, 149 | Type|string|\ReflectionClass|\ReflectionType|null $returnType = null 150 | ): PromiseInterface { 151 | return $this->context->executeActivity($type, $args, $options, $returnType); 152 | } 153 | 154 | public function newActivityStub(string $class, ?ActivityOptionsInterface $options = null): object 155 | { 156 | /** @var ActivityProxy $activityProxy */ 157 | $activityProxy = $this->context->newActivityStub($class, $options); 158 | 159 | $reflection = new \ReflectionClass($activityProxy); 160 | $properties = collect($reflection->getProperties()) 161 | ->each(fn (\ReflectionProperty $property) => $property->setAccessible(true)) 162 | ->mapWithKeys(fn (\ReflectionProperty $property) => [$property->getName() => $property->getValue($activityProxy)]); 163 | 164 | // @phpstan-ignore-next-line 165 | return new ActivityProxy( 166 | $properties->get('class'), 167 | $properties->get('activities'), 168 | $properties->get('options'), 169 | $this, 170 | $properties->get('callsInterceptor') 171 | ); 172 | } 173 | 174 | public function newUntypedActivityStub(?ActivityOptionsInterface $options = null): ActivityStubInterface 175 | { 176 | return new FakeActivityStub($this->context->newUntypedActivityStub($options)); 177 | } 178 | 179 | public function await(...$conditions): PromiseInterface 180 | { 181 | return $this->context->await(...$conditions); 182 | } 183 | 184 | public function awaitWithTimeout($interval, ...$conditions): PromiseInterface 185 | { 186 | return $this->context->awaitWithTimeout($interval, ...$conditions); 187 | } 188 | 189 | public function getStackTrace(): string 190 | { 191 | return $this->context->getStackTrace(); 192 | } 193 | 194 | public function upsertSearchAttributes(array $searchAttributes): void 195 | { 196 | $this->context->upsertSearchAttributes($searchAttributes); 197 | } 198 | 199 | public function uuid(): PromiseInterface 200 | { 201 | return $this->sideEffect(static fn (): UuidInterface => Uuid::uuid4()); 202 | } 203 | 204 | public function uuid4(): PromiseInterface 205 | { 206 | return $this->sideEffect(static fn (): UuidInterface => Uuid::uuid4()); 207 | } 208 | 209 | public function uuid7(?DateTimeInterface $dateTime = null): PromiseInterface 210 | { 211 | return $this->sideEffect(static fn (): UuidInterface => Uuid::uuid7($dateTime)); 212 | } 213 | 214 | public function registerUpdate(string $name, callable $handler, ?callable $validator): static 215 | { 216 | $this->context->registerUpdate($name, $handler, $validator); 217 | 218 | return $this; 219 | } 220 | 221 | public function allHandlersFinished(): bool 222 | { 223 | return $this->context->allHandlersFinished(); 224 | } 225 | 226 | public function upsertMemo(array $values): void 227 | { 228 | $this->context->upsertMemo($values); 229 | } 230 | 231 | public function upsertTypedSearchAttributes(SearchAttributeUpdate ...$updates): void 232 | { 233 | $this->context->upsertTypedSearchAttributes(...$updates); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/Testing/Fakes/FakeWorkflowRun.php: -------------------------------------------------------------------------------- 1 | stub->getExecution(); 20 | } 21 | 22 | /** 23 | * {@inheritDoc} 24 | */ 25 | public function getResult($type = null, ?int $timeout = null): mixed 26 | { 27 | return $this->returnValue; 28 | } 29 | 30 | public function describe(): WorkflowExecutionDescription 31 | { 32 | return $this->stub->describe(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Testing/Fakes/TemporalFake.php: -------------------------------------------------------------------------------- 1 | temporalMocker = $this->app->make(TemporalMocker::class); 36 | $this->swapWorkflowClient(); 37 | } 38 | 39 | protected function swapWorkflowClient(): void 40 | { 41 | $this->app->instance(WorkflowClientInterface::class, new FakeWorkflowClient( 42 | serviceClient: $this->app->make(ServiceClientInterface::class), 43 | options: (new ClientOptions)->withNamespace(config('temporal.namespace')), 44 | converter: $this->app->make(DataConverterInterface::class), 45 | interceptorProvider: new SimplePipelineProvider(array_map( 46 | fn (string $className) => $this->app->make($className), 47 | config('temporal.interceptors', []) 48 | )) 49 | )); 50 | } 51 | 52 | public function mockWorkflows(array $workflowMocks, ?string $taskQueue = null): void 53 | { 54 | $this->initCache(); 55 | 56 | foreach ($workflowMocks as $workflowName => $workflowResult) { 57 | if (is_int($workflowName) && is_string($workflowResult)) { 58 | $workflowName = $workflowResult; 59 | $workflowResult = null; 60 | } 61 | 62 | $this->temporalMocker->mockWorkflowResult($this->normalizeWorkflowName($workflowName), $workflowResult, $taskQueue); 63 | } 64 | } 65 | 66 | public function mockWorkflow(string $workflowName): WorkflowMockBuilder 67 | { 68 | $this->initCache(); 69 | 70 | return new WorkflowMockBuilder($this->normalizeWorkflowName($workflowName)); 71 | } 72 | 73 | public function mockActivities(array $activityMocks, ?string $taskQueue = null): void 74 | { 75 | $this->initCache(); 76 | 77 | $activityMocks = $this->normalizeActivityMocks($activityMocks); 78 | 79 | foreach ($activityMocks as $activityName => $activityResult) { 80 | if (is_int($activityName) && is_string($activityResult)) { 81 | $activityName = $activityResult; 82 | $activityResult = null; 83 | } 84 | 85 | $this->temporalMocker->mockActivityResult($activityName, $activityResult, $taskQueue); 86 | } 87 | } 88 | 89 | public function mockActivity(string|array $activityName): ActivityMockBuilder 90 | { 91 | $this->initCache(); 92 | 93 | return new ActivityMockBuilder($this->normalizeActivityName($activityName)); 94 | } 95 | 96 | public function assertWorkflowDispatched(string $workflowName, Closure|int|null $callback = null): void 97 | { 98 | if (is_int($callback)) { 99 | $this->assertWorkflowDispatchedTimes($workflowName, $callback); 100 | 101 | return; 102 | } 103 | 104 | PHPUnit::assertTrue( 105 | $this->workflowDispatched($workflowName, $callback)->count() > 0, 106 | sprintf('The expected [%s] workflow was not dispatched.', $workflowName) 107 | ); 108 | } 109 | 110 | public function assertWorkflowDispatchedTimes(string $workflowName, int $times = 1, ?Closure $callback = null): void 111 | { 112 | $count = $this->workflowDispatched($workflowName, $callback)->count(); 113 | 114 | PHPUnit::assertSame( 115 | $times, $count, 116 | sprintf('The expected [%s] workflow was dispatched %d times instead of %d times.', $workflowName, $count, $times) 117 | ); 118 | } 119 | 120 | public function assertWorkflowNotDispatched(string $workflowName, ?Closure $callback = null): void 121 | { 122 | PHPUnit::assertCount( 123 | 0, $this->workflowDispatched($workflowName, $callback), 124 | sprintf('The unexpected [%s] workflow was dispatched.', $workflowName) 125 | ); 126 | } 127 | 128 | protected function workflowDispatched(string $workflowName, ?Closure $callback = null): Collection 129 | { 130 | $callback = $callback ?: fn () => true; 131 | 132 | return collect($this->temporalMocker->getWorkflowDispatches($this->normalizeWorkflowName($workflowName)))->filter( 133 | fn (array $data) => $callback(...[...$data['args'], $data['taskQueue']]) 134 | ); 135 | } 136 | 137 | public function assertActivityDispatched(string|array $activityName, Closure|int|null $callback = null): void 138 | { 139 | $activityName = $this->normalizeActivityName($activityName); 140 | 141 | if (is_int($callback)) { 142 | $this->assertActivityDispatchedTimes($activityName, $callback); 143 | 144 | return; 145 | } 146 | 147 | PHPUnit::assertTrue( 148 | $this->activityDispatched($activityName, $callback)->count() > 0, 149 | sprintf('The expected [%s] activity was not dispatched.', $activityName) 150 | ); 151 | } 152 | 153 | public function assertActivityDispatchedTimes(string|array $activityName, int $times = 1, ?Closure $callback = null): void 154 | { 155 | $activityName = $this->normalizeActivityName($activityName); 156 | 157 | $count = $this->activityDispatched($activityName, $callback)->count(); 158 | 159 | PHPUnit::assertSame( 160 | $times, $count, 161 | sprintf('The expected [%s] activity was dispatched %d times instead of %d times.', $activityName, $count, $times) 162 | ); 163 | } 164 | 165 | public function assertActivityNotDispatched(string|array $activityName, ?Closure $callback = null): void 166 | { 167 | $activityName = $this->normalizeActivityName($activityName); 168 | 169 | PHPUnit::assertCount( 170 | 0, $this->activityDispatched($activityName, $callback), 171 | sprintf('The unexpected [%s] activity was dispatched.', $activityName) 172 | ); 173 | } 174 | 175 | protected function activityDispatched(string|array $activityName, ?Closure $callback = null): Collection 176 | { 177 | $activityName = $this->normalizeActivityName($activityName); 178 | 179 | $callback = $callback ?: fn () => true; 180 | 181 | return collect($this->temporalMocker->getActivityDispatches($activityName))->filter( 182 | fn (array $data) => $callback(...[...$data['args'], $data['taskQueue']]) 183 | ); 184 | } 185 | 186 | public function getTemporalContext(): Workflow\ScopedContextInterface 187 | { 188 | $currentContext = Workflow::getCurrentContext(); 189 | assert($currentContext instanceof Workflow\ScopedContextInterface); 190 | 191 | return new FakeScopeContext($currentContext); 192 | } 193 | 194 | protected function initCache(): void 195 | { 196 | if (! $this->activityCacheCleared) { 197 | $this->temporalMocker->clear(); 198 | } 199 | 200 | $this->activityCacheCleared = true; 201 | } 202 | 203 | public function useLocalCache(): TemporalFake 204 | { 205 | $this->temporalMocker->localOnly(); 206 | 207 | return $this; 208 | } 209 | 210 | protected function normalizeWorkflowName(string $workflowName): string 211 | { 212 | if (! interface_exists($workflowName) && ! class_exists($workflowName)) { 213 | return $workflowName; 214 | } 215 | 216 | try { 217 | return (new WorkflowReader(new AttributeReader))->fromClass($workflowName)->getID(); 218 | } catch (\Exception) { 219 | return $workflowName; 220 | } 221 | } 222 | 223 | protected function normalizeActivityMocks(array $activityMocks): array 224 | { 225 | return Collection::make($activityMocks) 226 | ->mapWithKeys(function (mixed $mocks, string $activity): array { 227 | if (interface_exists($activity) && is_array($mocks)) { 228 | return Collection::make($mocks) 229 | ->mapWithKeys(function (mixed $value, string $method) use ($activity): array { 230 | $activityName = $this->normalizeActivityName([$activity, $method]); 231 | 232 | return $activityName ? [$activityName => $value] : []; 233 | }) 234 | ->all(); 235 | } 236 | 237 | return [$activity => $mocks]; 238 | }) 239 | ->all(); 240 | } 241 | 242 | protected function normalizeActivityName(string|array $activityName): ?string 243 | { 244 | if (is_string($activityName)) { 245 | return $activityName; 246 | } 247 | 248 | if (count($activityName) !== 2) { 249 | return null; 250 | } 251 | 252 | if (! interface_exists($activityName[0]) && ! class_exists($activityName[0])) { 253 | return null; 254 | } 255 | 256 | try { 257 | /** @var ActivityPrototype[] $activities */ 258 | $activities = (new ActivityReader(new AttributeReader))->fromClass($activityName[0]); 259 | 260 | if ($activities === []) { 261 | return null; 262 | } 263 | 264 | $prefix = $activities[0]->getClass() 265 | ->getAttributes($activities[0]->isLocalActivity() ? LocalActivityInterface::class : ActivityInterface::class)[0] 266 | ->getArguments()['prefix'] ?? ''; 267 | 268 | /** @var Collection $activityMap */ 269 | $activityMap = Collection::make($activities) 270 | ->mapWithKeys(fn (ActivityPrototype $prototype) => [ 271 | $prototype->getID() => $prototype->getID(), 272 | Str::after($prototype->getID(), $prefix) => $prototype->getID(), 273 | $prototype->getHandler()->getName() => $prototype->getID(), 274 | ]); 275 | 276 | return $activityMap->get($activityName[1]); 277 | } catch (\Exception) { 278 | return null; 279 | } 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /src/Testing/LocalTemporalServer.php: -------------------------------------------------------------------------------- 1 | debug = $debug; 40 | 41 | return $this; 42 | } 43 | 44 | public function start(?int $port = null): void 45 | { 46 | $binaryPath = $this->ensureTemporalCliIsInstalled(); 47 | 48 | $this->startServer($binaryPath, $port); 49 | } 50 | 51 | public function isRunning(): bool 52 | { 53 | return $this->temporalServerProcess?->isRunning() ?? false; 54 | } 55 | 56 | public function stop(): void 57 | { 58 | if ($this->temporalServerProcess === null) { 59 | return; 60 | } 61 | 62 | $this->temporalServerProcess->signal(SIGTERM); 63 | 64 | do { 65 | usleep(1_000); 66 | } while ($this->temporalServerProcess->isRunning()); 67 | } 68 | 69 | protected function ensureTemporalCliIsInstalled(): string 70 | { 71 | if ($this->binaryPath !== null) { 72 | return $this->binaryPath; 73 | } 74 | 75 | if (file_exists(base_path('temporal'))) { 76 | return $this->binaryPath = base_path('temporal'); 77 | } 78 | 79 | $binaryPath = (new ExecutableFinder)->find('temporal', null, [base_path()]); 80 | 81 | if ($binaryPath) { 82 | return $this->binaryPath = $binaryPath; 83 | } 84 | 85 | return $this->binaryPath = $this->downloadTemporalCli(); 86 | } 87 | 88 | protected function downloadTemporalCli(): string 89 | { 90 | $os = OperatingSystem::createFromGlobals(); 91 | $arch = Architecture::createFromGlobals(); 92 | 93 | $client = HttpClient::create(); 94 | $response = $client->request('GET', self::TEMPORAL_CLI_RELEASES); 95 | $content = $response->toArray(); 96 | 97 | $asset = Arr::first($content['assets'], fn (array $asset) => rescue( 98 | fn () => \Safe\preg_match(sprintf('#temporal_cli_.+_%s_%s\..+#', $os, $arch), $asset['name']) === 1, 99 | false 100 | )); 101 | 102 | if ($asset === null) { 103 | throw new RuntimeException(sprintf('Could not find a Temporal CLI binary for %s %s', $os, $arch)); 104 | } 105 | 106 | $response = $client->request('GET', $asset['browser_download_url']); 107 | 108 | $assetPath = base_path($asset['name']); 109 | \Safe\file_put_contents($assetPath, $response->getContent()); 110 | 111 | $filename = match ($os) { 112 | OperatingSystem::OS_WINDOWS => 'temporal.exe', 113 | default => 'temporal', 114 | }; 115 | 116 | $phar = new PharData($assetPath); 117 | $phar->extractTo(base_path(), [$filename], true); 118 | \Safe\unlink($phar->getPath()); 119 | 120 | return base_path($filename); 121 | } 122 | 123 | protected function startServer(string $binaryPath, ?int $port): void 124 | { 125 | $this->debugOutput('Starting Temporal server... ', newLine: false); 126 | 127 | $temporalAddress = config('temporal.address', '127.0.0.1:7233'); 128 | $temporalPort = $port ?? (int) \Safe\parse_url((string) $temporalAddress, PHP_URL_PORT); 129 | 130 | $this->temporalServerProcess = new Process( 131 | command: [ 132 | $binaryPath, 133 | 'server', 134 | 'start-dev', 135 | '-p', 136 | (string) $temporalPort, 137 | '--log-level', 138 | 'error', 139 | ], 140 | timeout: 10 141 | ); 142 | 143 | $this->temporalServerProcess->start(); 144 | 145 | try { 146 | $serverStarted = $this->temporalServerProcess->waitUntil( 147 | fn ($type, $output) => Str::contains((string) $output, [ 148 | 'http server started', 149 | 'Temporal server is running', 150 | 'Temporal server:', 151 | 'Server:', 152 | ]) 153 | ); 154 | } catch (\Throwable) { 155 | $serverStarted = false; 156 | } 157 | 158 | if (! $serverStarted) { 159 | $this->debugOutput('error'); 160 | $this->debugOutput($this->temporalServerProcess->getErrorOutput()); 161 | 162 | throw new RuntimeException(sprintf('Failed to start Temporal test server: %s', $this->temporalServerProcess->getErrorOutput())); 163 | } 164 | 165 | $this->debugOutput('done.'); 166 | $this->debugOutput($this->temporalServerProcess->getOutput()); 167 | } 168 | 169 | protected function debugOutput(string $message, bool $newLine = true): void 170 | { 171 | if (! $this->debug) { 172 | return; 173 | } 174 | 175 | if ($newLine) { 176 | $this->output->writeln($message); 177 | } else { 178 | $this->output->write($message); 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/Testing/TemporalMocker.php: -------------------------------------------------------------------------------- 1 | cache->localOnly(); 14 | } 15 | 16 | public function clear(): void 17 | { 18 | $this->cache->clear(); 19 | } 20 | 21 | public function mockWorkflowResult(string $workflowName, mixed $workflowResult, ?string $taskQueue = null): void 22 | { 23 | $result = $workflowResult instanceof Closure ? $workflowResult() : $workflowResult; 24 | 25 | $this->cache->saveWorkflowMock($workflowName, $result, $taskQueue); 26 | } 27 | 28 | public function getWorkflowResult(string $workflowName, string $taskQueue): ?Closure 29 | { 30 | return $this->cache->getWorkflowMock($workflowName, $taskQueue); 31 | } 32 | 33 | public function mockActivityResult(string $activityName, mixed $activityResult, ?string $taskQueue = null): void 34 | { 35 | $result = $activityResult instanceof Closure ? $activityResult() : $activityResult; 36 | 37 | $this->cache->saveActivityMock($activityName, $result, $taskQueue); 38 | } 39 | 40 | public function getActivityResult(string $activityName, ?string $taskQueue): ?Closure 41 | { 42 | return $this->cache->getActivityMock($activityName, $taskQueue); 43 | } 44 | 45 | public function recordWorkflowDispatch(string $workflowName, string $taskQueue, array $args): void 46 | { 47 | $this->cache->recordWorkflowDispatch($workflowName, $taskQueue, $args); 48 | } 49 | 50 | public function getWorkflowDispatches(string $workflowName): array 51 | { 52 | return $this->cache->getWorkflowDispatches($workflowName); 53 | } 54 | 55 | public function recordActivityDispatch(string $activityName, ?string $taskQueue, array $args): void 56 | { 57 | $this->cache->recordActivityDispatch($activityName, $taskQueue, $args); 58 | } 59 | 60 | public function getActivityDispatches(string $activityName): array 61 | { 62 | return $this->cache->getActivityDispatches($activityName); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Testing/TemporalMockerCache.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | private readonly Collection $localCache; 25 | 26 | private bool $localOnly = false; 27 | 28 | /** 29 | * @param non-empty-string $host 30 | * @param non-empty-string $cacheName 31 | */ 32 | public function __construct(string $host, string $cacheName) 33 | { 34 | $this->cache = (new Factory(RPC::create($host)))->select($cacheName); 35 | $this->localCache = Collection::make(); 36 | } 37 | 38 | public static function create(): self 39 | { 40 | return new self( 41 | sprintf('tcp://127.0.0.1:%d', config('temporal.rpc_port', 6001)), 42 | self::CACHE_NAME 43 | ); 44 | } 45 | 46 | public function localOnly(): void 47 | { 48 | $this->localOnly = true; 49 | } 50 | 51 | public function clear(): void 52 | { 53 | $this->cacheProxy(fn () => $this->cache->clear()); 54 | } 55 | 56 | public function saveWorkflowMock(string $workflowName, mixed $value, ?string $taskQueue = null): void 57 | { 58 | $key = sprintf('workflow::%s', $workflowName); 59 | 60 | $payload = [ 61 | 'mock' => $value ?? 'null', 62 | 'taskQueue' => $taskQueue, 63 | ]; 64 | 65 | $this->cacheProxy( 66 | fn () => $this->cache->set($key, $payload), 67 | fn () => $this->localCache->put($key, $payload) 68 | ); 69 | } 70 | 71 | public function getWorkflowMock(string $workflowName, string $taskQueue): ?Closure 72 | { 73 | $key = sprintf('workflow::%s', $workflowName); 74 | 75 | $value = $this->cacheProxy( 76 | fn () => $this->cache->get($key), 77 | fn () => $this->localCache->get($key) 78 | ); 79 | 80 | if (! is_array($value)) { 81 | return null; 82 | } 83 | 84 | if (! Arr::has($value, 'mock')) { 85 | return null; 86 | } 87 | 88 | if (Arr::get($value, 'taskQueue') !== null && $value['taskQueue'] !== $taskQueue) { 89 | return null; 90 | } 91 | 92 | return match (Arr::get($value, 'mock')) { 93 | 'null' => fn () => null, 94 | null => null, 95 | default => fn () => $value['mock'] 96 | }; 97 | } 98 | 99 | public function saveActivityMock(string $activityName, mixed $value, ?string $taskQueue = null): void 100 | { 101 | $this->cacheProxy(fn () => $this->cache->set(sprintf('activity::%s', $activityName), [ 102 | 'mock' => $value ?? 'null', 103 | 'taskQueue' => $taskQueue, 104 | ])); 105 | } 106 | 107 | public function getActivityMock(string $activityName, ?string $taskQueue): ?Closure 108 | { 109 | $value = $this->cache->get(sprintf('activity::%s', $activityName)); 110 | 111 | if (! is_array($value)) { 112 | return null; 113 | } 114 | 115 | if (! Arr::has($value, 'mock')) { 116 | return null; 117 | } 118 | 119 | if (Arr::get($value, 'taskQueue') !== null && $value['taskQueue'] !== $taskQueue) { 120 | return null; 121 | } 122 | 123 | return match (Arr::get($value, 'mock')) { 124 | 'null' => fn () => null, 125 | null => null, 126 | default => fn () => $value['mock'] 127 | }; 128 | } 129 | 130 | public function recordWorkflowDispatch(string $workflowName, string $taskQueue, array $args): void 131 | { 132 | $key = sprintf('workflow_dispatch::%s', $workflowName); 133 | 134 | /** @var array $dispatches */ 135 | $dispatches = $this->cacheProxy( 136 | fn () => $this->cache->get($key, []), 137 | fn () => $this->localCache->get($key, []) 138 | ); 139 | 140 | $dispatches[] = [ 141 | 'taskQueue' => $taskQueue, 142 | 'args' => $args, 143 | ]; 144 | 145 | $this->cacheProxy( 146 | fn () => $this->cache->set($key, $dispatches), 147 | fn () => $this->localCache->put($key, $dispatches) 148 | ); 149 | } 150 | 151 | public function getWorkflowDispatches(string $workflowName): array 152 | { 153 | $key = sprintf('workflow_dispatch::%s', $workflowName); 154 | 155 | return $this->cacheProxy( 156 | fn () => $this->cache->get($key, []), 157 | fn () => $this->localCache->get($key, []) 158 | ); 159 | } 160 | 161 | public function recordActivityDispatch(string $activityName, ?string $taskQueue, array $args): void 162 | { 163 | $cacheKey = sprintf('activity_dispatch::%s', $activityName); 164 | 165 | /** @var array $dispatches */ 166 | $dispatches = $this->cache->get($cacheKey, []); 167 | 168 | $dispatches[] = [ 169 | 'taskQueue' => $taskQueue, 170 | 'args' => $args, 171 | ]; 172 | 173 | $this->cache->set($cacheKey, $dispatches); 174 | } 175 | 176 | public function getActivityDispatches(string $activityName): array 177 | { 178 | return $this->cache->get(sprintf('activity_dispatch::%s', $activityName), []); 179 | } 180 | 181 | private function cacheProxy(Closure $action, ?Closure $fallback = null): mixed 182 | { 183 | if ($this->localOnly) { 184 | return $fallback?->__invoke(); 185 | } 186 | 187 | try { 188 | return retry(5, $action, fn (int $attempt) => $attempt * 100); 189 | } catch (\Exception) { 190 | $this->localOnly = true; 191 | 192 | return $fallback?->__invoke(); 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/Testing/TemporalServer.php: -------------------------------------------------------------------------------- 1 | temporalServer = match ($this->timeSkipping) { 20 | true => TimeSkippingTemporalServer::create(), 21 | default => LocalTemporalServer::create(), 22 | }; 23 | $this->temporalWorker = TemporalTestingWorker::create(); 24 | } 25 | 26 | public static function create(bool $timeSkipping = false): self 27 | { 28 | return new self($timeSkipping); 29 | } 30 | 31 | public static function bootstrap(Application $app): void 32 | { 33 | $app->make(Kernel::class)->bootstrap(); 34 | 35 | $env = static::create(); 36 | 37 | $env->start(! config('temporal.testing.server', true)); 38 | 39 | register_shutdown_function(fn () => $env->stop()); 40 | } 41 | 42 | public function setDebugOutput(bool $debug): self 43 | { 44 | $this->debug = $debug; 45 | 46 | return $this; 47 | } 48 | 49 | public function start(bool $onlyWorker = false): void 50 | { 51 | if (! $onlyWorker) { 52 | $this->startTemporalServer(); 53 | } 54 | 55 | $this->startTemporalWorker(); 56 | } 57 | 58 | public function stop(): void 59 | { 60 | $this->temporalWorker->stop(); 61 | $this->temporalServer->stop(); 62 | } 63 | 64 | public function startTemporalServer(): void 65 | { 66 | $this->temporalServer->setDebugOutput($this->debug)->start(); 67 | } 68 | 69 | public function startTemporalWorker(): void 70 | { 71 | $this->temporalWorker->setDebugOutput($this->debug)->start(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Testing/TemporalTestingWorker.php: -------------------------------------------------------------------------------- 1 | debug = $debug; 35 | 36 | return $this; 37 | } 38 | 39 | public function start(): void 40 | { 41 | $this->downloadRoadRunnerBinary(); 42 | 43 | $this->startTemporalWorker(); 44 | } 45 | 46 | public function stop(): void 47 | { 48 | if ($this->roadRunnerProcess === null) { 49 | return; 50 | } 51 | 52 | $this->roadRunnerProcess->signal(SIGTERM); 53 | 54 | do { 55 | usleep(1_000); 56 | } while ($this->roadRunnerProcess->isRunning()); 57 | } 58 | 59 | public function isRunning(): bool 60 | { 61 | return $this->roadRunnerProcess?->isRunning() ?? false; 62 | } 63 | 64 | protected function startTemporalWorker(): void 65 | { 66 | $clientKey = config('temporal.tls.client_key'); 67 | $clientCert = config('temporal.tls.client_cert'); 68 | $rootCa = config('temporal.tls.root_ca'); 69 | $serverName = config('temporal.tls.server_name'); 70 | 71 | $this->roadRunnerProcess = new Process( 72 | command: [ 73 | $this->roadRunnerBinary->binaryPath(), 74 | ...['-o', sprintf('version=%s', $this->roadRunnerBinary->configVersion())], 75 | ...['-o', sprintf('server.command=%s %s', (new PhpExecutableFinder)->find(), $this->findWorkerPath())], 76 | ...['-o', sprintf('temporal.address=%s', config('temporal.address'))], 77 | ...['-o', sprintf('temporal.namespace=%s', config('temporal.namespace'))], 78 | ...(is_string($clientKey) && is_string($clientCert)) 79 | ? ['-o', sprintf('temporal.tls.key=%s', $clientKey), '-o', sprintf('temporal.tls.cert=%s', $clientCert), '-o', 'temporal.tls.client_auth_type=require_and_verify_client_cert'] 80 | : [], 81 | ...is_string($rootCa) ? ['-o', sprintf('temporal.tls.root_ca=%s', $rootCa)] : [], 82 | ...is_string($serverName) ? ['-o', sprintf('temporal.tls.server_name=%s', $serverName)] : [], 83 | ...['-o', sprintf('temporal.activities.num_workers=%s', 1)], 84 | ...['-o', sprintf('rpc.listen=tcp://127.0.0.1:%d', config('temporal.rpc_port', 6001))], 85 | ...['-o', 'logs.mode=none'], 86 | ...['-o', 'kv.test.driver=memory'], 87 | ...['-o', 'kv.test.config.interval=10'], 88 | 'serve', 89 | ], 90 | cwd: base_path(), 91 | env: [ 92 | ...$_SERVER, 93 | ...$_ENV, 94 | 'APP_ENV' => app()->environment(), 95 | 'APP_BASE_PATH' => base_path(), 96 | 'LARAVEL_TEMPORAL' => 1, 97 | 'TEMPORAL_QUEUE' => config('temporal.queue'), 98 | 'TEMPORAL_TESTING_ENV' => 1, 99 | 'TEMPORAL_TESTING_CONFIG' => \Safe\json_encode(config()->all()), 100 | 'TEMPORAL_TESTING_REGISTRY' => \Safe\json_encode(app(TemporalRegistry::class)->toArray()), 101 | ], 102 | timeout: 10 103 | ); 104 | 105 | $this->debugOutput('Starting RoadRunner... ', newLine: false); 106 | 107 | $this->roadRunnerProcess->start(); 108 | 109 | try { 110 | $roadRunnerStarted = $this->roadRunnerProcess->waitUntil( 111 | fn ($type, $output) => Str::contains((string) $output, 'RoadRunner server started') 112 | ); 113 | } catch (\Throwable) { 114 | $roadRunnerStarted = false; 115 | } 116 | 117 | if (! $roadRunnerStarted) { 118 | $this->debugOutput('error'); 119 | $this->debugOutput($this->roadRunnerProcess->getErrorOutput()); 120 | throw new \RuntimeException(sprintf('Failed to start Temporal test worker: %s', $this->roadRunnerProcess->getErrorOutput())); 121 | } 122 | 123 | $this->debugOutput('done.'); 124 | $this->debugOutput($this->roadRunnerProcess->getOutput()); 125 | } 126 | 127 | protected function findWorkerPath(): string 128 | { 129 | $defaultWorkerPath = base_path('vendor/bin/roadrunner-temporal-worker'); 130 | 131 | if (file_exists($defaultWorkerPath)) { 132 | return $defaultWorkerPath; 133 | } 134 | 135 | return \Safe\realpath(__DIR__.'/../../bin/roadrunner-temporal-worker'); 136 | } 137 | 138 | protected function downloadRoadRunnerBinary(): void 139 | { 140 | $this->roadRunnerBinary->ensureConfigFileExists(); 141 | 142 | if ($this->roadRunnerBinary->binaryPath() !== null) { 143 | return; 144 | } 145 | 146 | $this->debugOutput('Download roadrunner binary... ', newLine: false); 147 | 148 | $this->roadRunnerBinary->download(); 149 | 150 | $this->debugOutput('done.'); 151 | } 152 | 153 | protected function debugOutput(string $message, bool $newLine = true): void 154 | { 155 | if (! $this->debug) { 156 | return; 157 | } 158 | 159 | if ($newLine) { 160 | $this->output->writeln($message); 161 | } else { 162 | $this->output->write($message); 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/Testing/TimeSkippingTemporalServer.php: -------------------------------------------------------------------------------- 1 | debug = $debug; 36 | 37 | return $this; 38 | } 39 | 40 | public function start(?int $port = null): void 41 | { 42 | $this->downloadTemporalServerExecutable(); 43 | 44 | $this->startTemporalServer($port); 45 | } 46 | 47 | public function isRunning(): bool 48 | { 49 | return $this->temporalServerProcess?->isRunning() ?? false; 50 | } 51 | 52 | public function stop(): void 53 | { 54 | if ($this->temporalServerProcess === null) { 55 | return; 56 | } 57 | 58 | $this->temporalServerProcess->signal(SIGTERM); 59 | 60 | do { 61 | usleep(1_000); 62 | } while ($this->temporalServerProcess->isRunning()); 63 | } 64 | 65 | protected function startTemporalServer(?int $port = null): void 66 | { 67 | $this->debugOutput('Starting Temporal test server... ', newLine: false); 68 | 69 | $temporalAddress = config('temporal.address', '127.0.0.1:7233'); 70 | $temporalPort = $port ?? (int) \Safe\parse_url((string) $temporalAddress, PHP_URL_PORT); 71 | 72 | $this->temporalServerProcess = new Process( 73 | command: [$this->systemInfo->temporalServerExecutable, (string) $temporalPort, '--enable-time-skipping'], 74 | timeout: 10 75 | ); 76 | 77 | $this->temporalServerProcess->start(); 78 | 79 | usleep(10_000); 80 | 81 | if (! $this->temporalServerProcess->isRunning()) { 82 | $this->debugOutput('error'); 83 | $this->debugOutput($this->temporalServerProcess->getErrorOutput()); 84 | 85 | throw new \RuntimeException(sprintf('Failed to start Temporal test server: %s', $this->temporalServerProcess->getErrorOutput())); 86 | } 87 | 88 | $this->debugOutput('done.'); 89 | $this->debugOutput($this->temporalServerProcess->getOutput()); 90 | } 91 | 92 | protected function downloadTemporalServerExecutable(): void 93 | { 94 | if ($this->downloader->check($this->systemInfo->temporalServerExecutable)) { 95 | return; 96 | } 97 | 98 | $this->debugOutput('Download temporal test server... ', newLine: false); 99 | 100 | $this->downloader->download($this->systemInfo); 101 | 102 | $this->debugOutput('done.'); 103 | } 104 | 105 | protected function debugOutput(string $message, bool $newLine = true): void 106 | { 107 | if (! $this->debug) { 108 | return; 109 | } 110 | 111 | if ($newLine) { 112 | $this->output->writeln($message); 113 | } else { 114 | $this->output->write($message); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Testing/WithTemporal.php: -------------------------------------------------------------------------------- 1 | setDebugOutput(config('temporal.testing.debug', false)); 19 | 20 | $temporalEnvironment->start(onlyWorker: ! config('temporal.testing.server', true)); 21 | 22 | $GLOBALS['_temporal_environment'] = $temporalEnvironment; 23 | 24 | register_shutdown_function(function () use ($temporalEnvironment): void { 25 | $temporalEnvironment->stop(); 26 | $GLOBALS['_temporal_environment'] = null; 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Testing/WithTemporalWorker.php: -------------------------------------------------------------------------------- 1 | setDebugOutput(config('temporal.testing.debug', false)); 19 | 20 | $temporalEnvironment->start(onlyWorker: true); 21 | 22 | $GLOBALS['_temporal_environment'] = $temporalEnvironment; 23 | 24 | register_shutdown_function(function () use ($temporalEnvironment): void { 25 | $temporalEnvironment->stop(); 26 | $GLOBALS['_temporal_environment'] = null; 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Testing/WorkflowMock.php: -------------------------------------------------------------------------------- 1 | assertDispatchedTimes($callback); 19 | 20 | return; 21 | } 22 | 23 | Temporal::assertWorkflowDispatched($this->workflowName, function (...$args) use ($callback) { 24 | $taskQueue = Arr::last($args); 25 | 26 | if ($this->taskQueue !== null && $this->taskQueue !== $taskQueue) { 27 | return false; 28 | } 29 | 30 | if ($callback !== null) { 31 | return $callback(...$args); 32 | } 33 | 34 | return true; 35 | }); 36 | } 37 | 38 | public function assertDispatchedTimes(int $times = 1, ?\Closure $callback = null): void 39 | { 40 | Temporal::assertWorkflowDispatchedTimes($this->workflowName, $times, function (...$args) use ($callback) { 41 | $taskQueue = Arr::last($args); 42 | 43 | if ($this->taskQueue !== null && $this->taskQueue !== $taskQueue) { 44 | return false; 45 | } 46 | 47 | if ($callback !== null) { 48 | return $callback(...$args); 49 | } 50 | 51 | return true; 52 | }); 53 | } 54 | 55 | public function assertNotDispatched(?\Closure $callback = null): void 56 | { 57 | Temporal::assertWorkflowNotDispatched($this->workflowName, function (...$args) use ($callback) { 58 | $taskQueue = Arr::last($args); 59 | 60 | if ($this->taskQueue !== null && $this->taskQueue !== $taskQueue) { 61 | return false; 62 | } 63 | 64 | if ($callback !== null) { 65 | return $callback(...$args); 66 | } 67 | 68 | return true; 69 | }); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Testing/WorkflowMockBuilder.php: -------------------------------------------------------------------------------- 1 | taskQueue = $taskQueue; 16 | 17 | return $this; 18 | } 19 | 20 | public function andReturn(mixed $returnValue): WorkflowMock 21 | { 22 | Temporal::mockWorkflows([ 23 | $this->workflowName => $returnValue, 24 | ], $this->taskQueue); 25 | 26 | return new WorkflowMock($this->workflowName, $this->taskQueue); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /stubs/phpstan/ActivityProxy.stub: -------------------------------------------------------------------------------- 1 |