Search by

webalternatif / temporal-bundle

chapa

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

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-10-31 19:40 UTC

This package is auto-updated.

Last update: 2026-10-31 19:41:31 UTC


README

Source code Packagist Version Software license GitHub issues
Test status Mutation testing badge

This bundle integrates the Temporal PHP SDK into Symfony applications:

  • Temporal clients (WorkflowClient and ScheduleClient) 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 grpc PHP extension is required by the Temporal clients (i.e. to start workflows).
  • RoadRunner is required to run workers. The rr binary can be downloaded with vendor/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.