webalternatif / temporal-bundle
A bundle to integrate Temporal workflows and activities into Symfony applications
Package info
github.com/webalternatif/temporal-bundle
Type:symfony-bundle
pkg:composer/webalternatif/temporal-bundle
Requires
- php: 8.3.* || 8.4.* || 8.5.*
- psr/clock: ^1.0
- psr/container: ^1.1 || ^2.0
- symfony/clock: ^6.4 || ^7.4 || ^8.1
- symfony/config: ^6.4.37 || ^7.4.9 || ^8.1
- symfony/console: ^6.4 || ^7.4 || ^8.1
- symfony/dependency-injection: ^6.4.19 || ^7.4 || ^8.1
- symfony/http-client: ^6.4 || ^7.4 || ^8.1
- symfony/http-client-contracts: ^3.0
- symfony/http-kernel: ^6.4 || ^7.4 || ^8.1
- temporal/sdk: ^2.18
Requires (Dev)
- carthage-software/mago: ^1.59
- doctrine/dbal: ^3.1.4 || ^4.2
- doctrine/orm: ^2.10 || ^3.3
- doctrine/persistence: ^2.2 || ^3.4 || ^4.0
- infection/infection: ^0.35.8
- phpunit/phpunit: ^12.5
- symfony/cache: ^6.4 || ^7.4 || ^8.1
- symfony/framework-bundle: ^6.4 || ^7.4 || ^8.1
Suggests
- doctrine/orm: To reset Doctrine entity managers before each activity
- symfony/cache: To share OIDC tokens between processes (oidc.cache_pool option)
Provides
None
Conflicts
- doctrine/dbal: <3.1.4
- doctrine/orm: <2.10
Replaces
None
README
This bundle integrates the Temporal PHP SDK into Symfony applications:
- Temporal clients (
WorkflowClientandScheduleClient) are available as services, configured from the bundle configuration. - Workflows and activities are discovered from their Temporal attributes, and activities are regular services (with autowiring).
- A console command runs workers inside RoadRunner.
- Temporal API keys can be static or fetched from an OIDC server, and are kept fresh in long-running processes.
- Doctrine entity managers are reset before each activity, like Messenger does between two messages.
This project adheres to Semantic Versioning.
Installation
Make sure Composer is installed globally, as explained in the installation chapter of the Composer documentation.
Applications that use Symfony Flex
Open a command console, enter your project directory and execute:
$ composer require webalternatif/temporal-bundle
Applications that don't use Symfony Flex
Step 1: Download the Bundle
Open a command console, enter your project directory and execute the following command to download the latest stable version of this bundle:
$ composer require webalternatif/temporal-bundle
Step 2: Enable the Bundle
Then, enable the bundle by adding it to the list of registered bundles in the
config/bundles.php file of your project:
// config/bundles.php return [ // ... Webf\TemporalBundle\WebfTemporalBundle::class => ['all' => true], ];
Requirements
- The
grpcPHP extension is required by the Temporal clients (i.e. to start workflows). - RoadRunner is required to run workers. The
rrbinary can be downloaded withvendor/bin/rr get-binary.
Configuration
The only required option is the address of the Temporal server:
# config/packages/webf_temporal.yaml webf_temporal: address: '%env(TEMPORAL_ADDRESS)%' # e.g. "localhost:7233"
Secrets (api_key, oidc.client_secret) should also be given with
environment variables, such as '%env(TEMPORAL_API_KEY)%': a value
written in the configuration is dumped as it is in the compiled container
(in var/cache/).
Here is the full configuration reference, with default values:
webf_temporal: # Address of the Temporal frontend service (e.g. "localhost:7233") address: ~ # Required # Static API key used to authenticate to the Temporal server api_key: null # Temporal namespace used by the clients namespace: default # Fetch API keys from an OIDC server with the client credentials grant # (cannot be used with "api_key") oidc: enabled: false # Cache pool that shares tokens between processes (null to keep them in the memory of each process) cache_pool: null # How the client authenticates to the token endpoint (HTTP Basic is the one every server supports) client_auth_method: client_secret_basic # One of "client_secret_basic"; "client_secret_post" client_id: ~ # Required client_secret: ~ # Required token_endpoint: ~ # Required # Task queues polled by the "temporal:worker" command (or the name of a # string-backed enum whose values are the task queues) task_queues: [default] # Connect to the Temporal server with TLS tls: enabled: false client_certificate: null # Path of the client certificate (mTLS) client_key: null # Path of the client private key (mTLS) root_certificate: null # Path of the root certificate (system roots if null) server_name: null # Overrides the server name used to verify the certificate worker: # Maximum number of activities executed at the same time by each worker (Temporal default if null) max_concurrent_activity_execution_size: null # Clear Doctrine managers and check their connection before each activity (if DoctrineBundle is enabled) reset_doctrine_managers: true
Usage
Writing workflows and activities
Any service whose class has the #[ActivityInterface] or #[WorkflowInterface]
attribute is registered on workers, as long as it is autoconfigured (which is
the case of services loaded from the src/ directory with the default Symfony
configuration).
Activities are instantiated by the service container, so they can use dependency injection:
use App\Repository\OrderRepository; use Doctrine\ORM\EntityManagerInterface; use Temporal\Activity\ActivityInterface; use Temporal\Exception\Failure\ApplicationFailure; #[ActivityInterface] final readonly class OrderActivities { public function __construct( private EntityManagerInterface $entityManager, private OrderRepository $orderRepository, ) { } public function markAsShipped(int $orderId): void { $order = $this->orderRepository->find($orderId) ?? throw new ApplicationFailure("Order #{$orderId} not found.", 'OrderNotFound', nonRetryable: true); $order->markAsShipped(); $this->entityManager->flush(); } }
Workflows are instantiated by Temporal itself, so they must not have constructor dependencies:
use App\Temporal\Activity\OrderActivities; use Temporal\Activity\ActivityOptions; use Temporal\Workflow; use Temporal\Workflow\WorkflowInterface; use Temporal\Workflow\WorkflowMethod; #[WorkflowInterface] final class ShipOrderWorkflow { #[WorkflowMethod] public function ship(int $orderId): \Generator { $orderActivities = Workflow::newActivityStub( OrderActivities::class, ActivityOptions::new()->withStartToCloseTimeout('5s'), ); yield $orderActivities->markAsShipped($orderId); } }
Note
Symfony only looks for attributes on the class of a service, not on the
interfaces it implements: when #[ActivityInterface] is put on an interface,
tag the implementation with webf_temporal.activity yourself.
Starting workflows
Inject Temporal\Client\WorkflowClientInterface (or WorkflowClient) where you
need to start workflows:
use App\Entity\Order; use App\Temporal\Workflow\ShipOrderWorkflow; use Temporal\Client\WorkflowClientInterface; use Temporal\Client\WorkflowOptions; use Temporal\Common\IdReusePolicy; use Temporal\Common\WorkflowIdConflictPolicy; final readonly class OrderService { public function __construct( private WorkflowClientInterface $workflowClient, ) { } public function ship(Order $order): void { $workflow = $this->workflowClient->newWorkflowStub( ShipOrderWorkflow::class, WorkflowOptions::new() ->withWorkflowId(sprintf('ship-order-%d', $order->getId())) ->withWorkflowIdConflictPolicy(WorkflowIdConflictPolicy::UseExisting) ->withWorkflowIdReusePolicy(IdReusePolicy::AllowDuplicateFailedOnly), ); $this->workflowClient->start($workflow, $order->getId()); } }
Temporal\Client\ScheduleClientInterface (or ScheduleClient) can be injected
the same way to manage schedules.
Running workers
Workers are PHP processes managed by RoadRunner, which polls Temporal and gives
them workflow and activity tasks. Its configuration file (.rr.yaml) must run
the temporal:worker command:
version: "3" rpc: listen: tcp://127.0.0.1:6001 server: command: "php bin/console temporal:worker" temporal: address: "${TEMPORAL_ADDRESS}" namespace: "default" logs: level: info
Then start RoadRunner with rr serve.
The temporal:worker command polls every task queue listed in the
task_queues option. To dedicate a RoadRunner instance to a single task queue,
give its name to the command (e.g. php bin/console temporal:worker ${TASK_QUEUE}), it must be one of the task_queues option.
When task queues are already defined by a string-backed enum, its name can be given instead of the list, the task queues are then the values of its cases:
webf_temporal: task_queues: App\Temporal\TaskQueue
Note
RoadRunner connects to the Temporal server on its own: TLS must also be
configured in the temporal.tls section of .rr.yaml.
Authentication
With a static API key (api_key option), the key is given to the Temporal
clients and to RoadRunner when workers start.
With OIDC (oidc option), API keys are access tokens fetched with the
client credentials grant. The client authenticates with HTTP Basic, which
every server must support, unless client_auth_method is client_secret_post
(credentials in the request body). They are refreshed at half of their lifetime,
both by the Temporal clients and by the temporal:refresh-api-key command,
which pushes them to RoadRunner. This command must run alongside workers, as a
RoadRunner service:
# .rr.yaml service: temporal_api_key: command: "php bin/console temporal:refresh-api-key" remain_after_exit: true restart_sec: 5
The command pushes keys to RoadRunner through its RPC server, at the address
given by the RR_RPC environment variable (tcp://127.0.0.1:6001 by
default). When rpc.listen is another address, give it to the service:
# .rr.yaml rpc: listen: tcp://127.0.0.1:6002 service: temporal_api_key: command: "php bin/console temporal:refresh-api-key" env: RR_RPC: tcp://127.0.0.1:6002 remain_after_exit: true restart_sec: 5
The HTTP client of FrameworkBundle (http_client service) is used to fetch
tokens when available.
By default, each process (PHP-FPM request, RoadRunner worker...) keeps its
token in memory, so each of them fetches its own token. The cache_pool option
shares tokens between processes through a cache pool, such as cache.app. The
pool must implement Symfony\Contracts\Cache\CacheInterface, as every pool of
FrameworkBundle does (this needs symfony/cache).
Note
Tokens are stored as they are in the pool. cache.app is a filesystem pool
by default: use a pool with its own storage if tokens must not be written to
var/cache/.
Resetting services between activities
Workers are long-running processes, so a state left by an activity could leak
into the next ones. Before instantiating each activity, workers call every
service implementing Webf\TemporalBundle\Worker\ActivityResetterInterface
(autoconfigured with the webf_temporal.activity_resetter tag).
When DoctrineBundle is enabled, a resetter clears every Doctrine manager,
reconnects database connections closed by the server, and resets closed entity
managers. It can be disabled with the worker.reset_doctrine_managers option.
Services
| Service id | Aliases |
|---|---|
webf_temporal.workflow_client |
Temporal\Client\WorkflowClient, Temporal\Client\WorkflowClientInterface |
webf_temporal.schedule_client |
Temporal\Client\ScheduleClient, Temporal\Client\ScheduleClientInterface |
webf_temporal.service_client |
Temporal\Client\GRPC\ServiceClientInterface |
webf_temporal.client_options |
|
webf_temporal.worker_factory |
|
webf_temporal.worker_options |
Tests
To run all tests, execute the command:
composer test
This will run the Mago analyzer, PHPUnit, Infection, the Mago linter and a Mago formatting check, but you can run them individually like this:
composer analyze composer phpunit composer infection composer lint composer fmt-check
To format the code, execute composer fmt.
The CI also checks with ComposerRequireChecker that each package used by
the code is declared in composer.json. It is not a dev dependency, because
its latest version does not support all the PHP and Symfony versions of the
bundle. Once it is installed, run it like this:
composer-require-checker check --config-file=composer-require-checker.json
Releasing
Commits follow the conventional commits specification, which allows
generating the changelog with git-cliff. To release a new version, run
just release (see just), which updates CHANGELOG.md, then commits and
tags the new version. Then, push the release commit and its tag:
git push --follow-tags
Pushing the tag publishes a GitHub release with the notes of the new version.
License
This bundle is released under the MIT license.