Skip to content

CakePHP Temporal Plugin

Introduction

The CakePHP Temporal Plugin provides workflow orchestration for CakePHP applications using Temporal. Temporal is a distributed, scalable, durable, and highly available orchestration engine for asynchronous long-running business logic in a microservice architecture.

The plugin integrates seamlessly with CakePHP's dependency injection system, provides command line tools for generating workflows and activities, and supports auto-discovery of your workflow and activity classes. It also includes support for cross-language activities, allowing you to call Go, Java, or other language activities from PHP workflows.

Installation

You can install the plugin via Composer:

bash
composer require crustum/temporal

Requirements

  • PHP 8.2+
  • CakePHP 5.1+
  • Temporal Server (running locally or remotely)
  • RoadRunner (for worker execution)

Installing Temporal Server

You can run Temporal Server locally using Docker:

bash
docker run -p 7233:7233 temporalio/auto-setup:latest

Or install the Temporal CLI and run the development server:

bash
# Install Temporal CLI
# See: https://docs.temporal.io/cli

# Start development server
temporal server start-dev

Installing Plugin Assets

After installing the plugin via Composer, you need to install plugin assets (configuration files, worker scripts, etc.):

Step 1: Using PluginManifest (Recommended)

bash
bin/cake manifest install --plugin Temporal

This will:

  • Copy config/temporal.php to your application
  • Copy bin/roadrunner-worker.php to your application
  • Copy file watcher scripts to bin/watch/
  • Add configuration loading to config/bootstrap.php

Step 2: Using Temporal Install Command

bash
bin/cake temporal install

This will:

  • Download RoadRunner binary to bin/rr (or bin/rr.exe on Windows)
  • Install file watcher dependencies (npm install in bin/watch/)
  • Update .gitignore to exclude RoadRunner binaries

Installing RoadRunner

RoadRunner is required for running Temporal workers. The temporal install command will automatically download the RoadRunner binary, or you can install it manually:

bash
composer require spiral/roadrunner-cli --dev
./vendor/bin/rr get-binary

Quickstart

1. Load the Plugin

First, load the Temporal plugin in your Application.php:

php
// In src/Application.php
public function bootstrap(): void
{
    parent::bootstrap();

    $this->addPlugin('Crustum/Temporal');
}

2. Install Plugin Assets

Install plugin assets using PluginManifest:

bash
bin/cake manifest install --plugin Temporal

This automatically:

  • Copies the configuration file to config/temporal.php
  • Adds configuration loading to config/bootstrap.php
  • Copies worker scripts and file watcher files

Alternatively, the configuration loading is automatically added to config/bootstrap.php when you run manifest install.

3. Generate a Workflow

Generate your first workflow using the bake command:

bash
bin/cake bake temporal workflow OrderProcessing

This will create:

  • src/Workflows/OrderProcessingWorkflowInterface.php
  • src/Workflows/OrderProcessingWorkflow.php

4. Generate an Activity

Generate an activity to handle order creation:

bash
bin/cake bake temporal activity OrderCreation

This will create:

  • src/Activities/OrderCreationActivityInterface.php
  • src/Activities/OrderCreationActivity.php

5. Implement Your Workflow

php
// src/Workflows/OrderProcessingWorkflow.php
namespace App\Workflows;

use App\Activities\OrderCreationActivityInterface;
use Crustum\Temporal\Temporal;
use Temporal\Workflow;

class OrderProcessingWorkflow implements OrderProcessingWorkflowInterface
{
    public function execute(array $data)
    {
        $activity = Temporal::newActivity()
            ->build(OrderCreationActivityInterface::class);

        $order = yield $activity->create($data);

        return ['order_id' => $order['id'], 'status' => 'completed'];
    }
}

6. Implement Your Activity

php
// src/Activities/OrderCreationActivity.php
namespace App\Activities;

use App\Model\Table\OrdersTable;

class OrderCreationActivity implements OrderCreationActivityInterface
{
    public function __construct(
        private OrdersTable $ordersTable
    ) {
    }

    public function create(array $data): array
    {
        $order = $this->ordersTable->newEntity($data);
        $this->ordersTable->save($order);

        return $order->toArray();
    }
}

7. Register Activity in Container

Register your activity in Application::services():

php
// src/Application.php
public function services(ContainerInterface $container): void
{
    $container->add(OrderCreationActivity::class)
        ->addArgument(OrdersTable::class);
}

8. Start a Workflow

Start a workflow from your controller:

php
// src/Controller/OrdersController.php
use Crustum\Temporal\Temporal;
use App\Workflows\OrderProcessingWorkflowInterface;

public function create()
{
    $workflow = Temporal::newWorkflow()
        ->withWorkflowId('order-' . uniqid())
        ->build(OrderProcessingWorkflowInterface::class);

    $result = $workflow->execute([
        'user_id' => 1,
        'total' => 99.99,
    ]);

    $this->Flash->success('Order processing started');
    return $this->redirect(['action' => 'index']);
}

9. Start the Worker

Start the Temporal worker to execute workflows and activities:

bash
bin/cake temporal work

Your workflow will now execute automatically!

Configuration

All of your application's Temporal configuration is stored in the config/temporal.php configuration file:

php
<?php
declare(strict_types=1);

return [
    'Temporal' => [
        'address' => env('TEMPORAL_ADDRESS', 'localhost:7233'),
        'namespace' => env('TEMPORAL_NAMESPACE', 'default'),
        'queue' => env('TEMPORAL_QUEUE', 'php-queue'),
        'tls' => [
            'client_key' => env('TEMPORAL_TLS_CLIENT_KEY'),
            'client_cert' => env('TEMPORAL_TLS_CLIENT_CERT'),
            'root_ca' => env('TEMPORAL_TLS_ROOT_CA'),
            'server_name' => env('TEMPORAL_TLS_SERVER_NAME'),
        ],
        'retry' => [
            'workflow' => [
                'initial_interval' => 1,
                'backoff_coefficient' => 2.0,
                'maximum_interval' => 100,
                'maximum_attempts' => 0,
            ],
            'activity' => [
                'initial_interval' => 1,
                'backoff_coefficient' => 2.0,
                'maximum_interval' => 100,
                'maximum_attempts' => 3,
            ],
        ],
        'interceptors' => [],
        'discovery' => [
            'workflows' => [
                'src/Workflows',
            ],
            'activities' => [
                'src/Activities',
            ],
        ],
        'watch' => [
            'src',
        ],
        'testing' => [
            'server' => env('TEMPORAL_TESTING_SERVER', true),
            'time_skipping' => env('TEMPORAL_TESTING_TIME_SKIPPING', false),
            'debug' => env('TEMPORAL_TESTING_DEBUG', false),
            'address' => env('TEMPORAL_TESTING_ADDRESS', 'localhost:7233'),
            'namespace' => env('TEMPORAL_TESTING_NAMESPACE', 'default'),
        ],
    ],
];

Configuration Options

  • address: The Temporal server address (default: localhost:7233)
  • namespace: The Temporal namespace (default: default)
  • queue: The default task queue name (default: php-queue)
  • tls: TLS configuration for secure connections
    • client_key: Path to client key file
    • client_cert: Path to client certificate file
    • root_ca: Path to root CA certificate file
    • server_name: Override server name for certificate verification
  • retry: Default retry policies for workflows and activities
  • interceptors: Array of interceptor class names
  • discovery: Paths to discover workflows and activities automatically
  • watch: Directories to watch for file changes (used with --watch flag)
  • testing: Testing environment configuration
    • server: Whether to start Temporal server in tests (default: true)
    • time_skipping: Enable time skipping for tests (default: false)
    • debug: Enable debug output in tests (default: false)
    • address: Temporal server address for testing
    • namespace: Temporal namespace for testing

Environment Variables

You can override configuration values using environment variables:

ini
TEMPORAL_ADDRESS=localhost:7233
TEMPORAL_NAMESPACE=production
TEMPORAL_QUEUE=my-queue

Workflows

Workflows are the core abstraction in Temporal. They define the orchestration logic for your business processes. Workflows are deterministic, meaning they must produce the same result when replayed.

Generating Workflows

You can generate a new workflow using the bake command:

bash
bin/cake bake temporal workflow OrderApproval

This command will create:

  • src/Workflows/OrderApprovalWorkflowInterface.php - The workflow interface
  • src/Workflows/OrderApprovalWorkflow.php - The workflow implementation

The generated workflow includes:

  • A WorkflowInterface attribute
  • A WorkflowMethod attribute on the execute() method
  • Commented-out examples for QueryMethod and SignalMethod

Defining Workflows

A workflow consists of an interface and an implementation. The interface defines the workflow's contract:

php
<?php
declare(strict_types=1);

namespace App\Workflows;

use Temporal\Workflow\QueryMethod;
use Temporal\Workflow\SignalMethod;
use Temporal\Workflow\WorkflowInterface;
use Temporal\Workflow\WorkflowMethod;

#[WorkflowInterface]
interface OrderApprovalWorkflowInterface
{
    #[WorkflowMethod(name: "order-approval")]
    public function execute(array $data);

    #[QueryMethod(name: "getStatus")]
    public function getStatus(): array;

    #[SignalMethod]
    public function approve(array $data): void;

    #[SignalMethod]
    public function reject(array $data): void;
}

The implementation contains the workflow logic:

php
<?php
declare(strict_types=1);

namespace App\Workflows;

use App\Activities\OrderActivityInterface;
use Crustum\Temporal\Temporal;
use Temporal\Workflow;

class OrderApprovalWorkflow implements OrderApprovalWorkflowInterface
{
    private bool $approved = false;
    private ?array $rejectionData = null;
    private string $status = 'pending';

    public function execute(array $data)
    {
        $activity = Temporal::newActivity()
            ->build(OrderActivityInterface::class);

        $order = yield $activity->createOrder($data);

        yield Workflow::await(
            fn() => $this->approved || $this->rejectionData !== null
        );

        if ($this->rejectionData !== null) {
            $this->status = 'rejected';
            return ['status' => 'rejected', 'reason' => $this->rejectionData['reason']];
        }

        $this->status = 'approved';
        return ['status' => 'approved', 'order_id' => $order['id']];
    }

    public function getStatus(): array
    {
        return [
            'status' => $this->status,
            'approved' => $this->approved,
            'rejected' => $this->rejectionData !== null,
        ];
    }

    public function approve(array $data): void
    {
        $this->approved = true;
    }

    public function reject(array $data): void
    {
        $this->rejectionData = $data;
    }
}

Workflow Methods

Workflows can have three types of methods:

Workflow Methods

The main entry point for a workflow. Marked with #[WorkflowMethod]:

php
#[WorkflowMethod(name: "order-approval")]
public function execute(array $data);

Query Methods

Allow reading workflow state without affecting execution. Marked with #[QueryMethod]:

php
#[QueryMethod(name: "getStatus")]
public function getStatus(): array;

Signal Methods

Allow sending data to a running workflow. Marked with #[SignalMethod]:

php
#[SignalMethod]
public function approve(array $data): void;

Starting Workflows

You can start workflows from controllers, commands, or anywhere in your application:

php
use Crustum\Temporal\Temporal;
use App\Workflows\OrderApprovalWorkflowInterface;

// Simple - use defaults
$workflow = Temporal::newWorkflow()
    ->build(OrderApprovalWorkflowInterface::class);

$result = $workflow->execute([
    'user_id' => 1,
    'total' => 99.99,
]);

// With custom options
$workflow = Temporal::newWorkflow()
    ->withWorkflowId('order-' . $orderId)
    ->withTaskQueue('high-priority-queue')
    ->withWorkflowExecutionTimeout(new \DateInterval('PT1H'))
    ->withMemo(['order_id' => $orderId])
    ->build(OrderApprovalWorkflowInterface::class);

$result = $workflow->execute($orderData);

Workflow Builders

The WorkflowBuilder provides a fluent interface for configuring workflow options:

php
$workflow = Temporal::newWorkflow()
    ->withWorkflowId('unique-workflow-id')
    ->withTaskQueue('custom-queue')
    ->withWorkflowExecutionTimeout(new \DateInterval('PT1H'))
    ->withWorkflowRunTimeout(new \DateInterval('PT30M'))
    ->withWorkflowTaskTimeout(new \DateInterval('PT10S'))
    ->withMemo(['key' => 'value'])
    ->withSearchAttributes(['CustomKeyword' => 'value'])
    ->withRetryOptions(RetryOptions::new()->withMaximumAttempts(5))
    ->build(OrderApprovalWorkflowInterface::class);

Available methods:

  • withWorkflowId(string $id) - Set a custom workflow ID
  • withTaskQueue(string $queue) - Set the task queue
  • withWorkflowExecutionTimeout(DateInterval $timeout) - Maximum execution time
  • withWorkflowRunTimeout(DateInterval $timeout) - Maximum run time
  • withWorkflowTaskTimeout(DateInterval $timeout) - Task processing timeout
  • withMemo(array $memo) - Add metadata
  • withSearchAttributes(array $attributes) - Add searchable attributes
  • withRetryOptions(RetryOptions $options) - Configure retry policy

Activities

Activities are the building blocks of workflows. They represent units of work that can fail and be retried. Activities are executed by workers and can perform any operation: database queries, API calls, file processing, etc.

Generating Activities

Generate a new activity using the bake command:

bash
bin/cake bake temporal activity PaymentProcessing

This creates:

  • src/Activities/PaymentProcessingActivityInterface.php
  • src/Activities/PaymentProcessingActivity.php

Defining Activities

An activity consists of an interface and an implementation:

php
<?php
declare(strict_types=1);

namespace App\Activities;

use Temporal\Activity\ActivityInterface;
use Temporal\Activity\ActivityMethod;

#[ActivityInterface(prefix: "payment.")]
interface PaymentProcessingActivityInterface
{
    #[ActivityMethod(name: "process")]
    public function process(string $orderId, float $amount): array;
}

The implementation contains the activity logic:

php
<?php
declare(strict_types=1);

namespace App\Activities;

class PaymentProcessingActivity implements PaymentProcessingActivityInterface
{
    public function process(string $orderId, float $amount): array
    {
        // Process payment logic here
        $paymentId = 'pay_' . uniqid();

        return [
            'success' => true,
            'payment_id' => $paymentId,
            'amount' => $amount,
        ];
    }
}

Activity Methods

Activities can have multiple methods, each marked with #[ActivityMethod]:

php
#[ActivityInterface(prefix: "order.")]
interface OrderActivityInterface
{
    #[ActivityMethod(name: "create")]
    public function createOrder(array $data): array;

    #[ActivityMethod(name: "update")]
    public function updateOrder(string $orderId, array $data): array;

    #[ActivityMethod(name: "cancel")]
    public function cancelOrder(string $orderId): bool;
}

Calling Activities

Call activities from within workflows using the builder:

php
use Crustum\Temporal\Temporal;
use App\Activities\OrderActivityInterface;
use Temporal\Workflow;

class OrderWorkflow implements OrderWorkflowInterface
{
    public function execute(array $data)
    {
        // Simple - use defaults
        $activity = Temporal::newActivity()
            ->build(OrderActivityInterface::class);

        $order = yield $activity->createOrder($data);

        // With custom timeout and retry
        $paymentActivity = Temporal::newActivity()
            ->withTaskQueue('payment-queue')
            ->withStartToCloseTimeout(new \DateInterval('PT30S'))
            ->withRetryOptions(RetryOptions::new()->withMaximumAttempts(5))
            ->build(PaymentActivityInterface::class);

        $payment = yield $paymentActivity->processPayment($order['id'], $data['total']);

        return $order;
    }
}

Activity Builders

The ActivityBuilder provides a fluent interface for configuring activity options:

php
$activity = Temporal::newActivity()
    ->withTaskQueue('custom-queue')
    ->withStartToCloseTimeout(new \DateInterval('PT30S'))
    ->withScheduleToCloseTimeout(new \DateInterval('PT1M'))
    ->withScheduleToStartTimeout(new \DateInterval('PT10S'))
    ->withHeartbeatTimeout(new \DateInterval('PT5S'))
    ->withRetryOptions(RetryOptions::new()->withMaximumAttempts(3))
    ->withActivityId('unique-activity-id')
    ->build(OrderActivityInterface::class);

Available methods:

  • withTaskQueue(string $queue) - Set the task queue
  • withStartToCloseTimeout(DateInterval $timeout) - Maximum execution time
  • withScheduleToCloseTimeout(DateInterval $timeout) - Maximum time from schedule to completion
  • withScheduleToStartTimeout(DateInterval $timeout) - Maximum time from schedule to start
  • withHeartbeatTimeout(DateInterval $timeout) - Heartbeat timeout
  • withRetryOptions(RetryOptions $options) - Configure retry policy
  • withActivityId(string $id) - Set a custom activity ID

Dependency Injection

Activities can receive dependencies via constructor injection. Register activities in your Application::services() method:

php
// src/Application.php
public function services(ContainerInterface $container): void
{
    // Register table classes
    $container->add(OrdersTable::class, function () use ($container) {
        return FactoryLocator::get('Table')->get('Orders');
    });

    // Register activities with dependencies
    $container->add(OrderActivity::class)
        ->addArgument(OrdersTable::class);

    $container->add(PaymentActivity::class);

    $container->add(NotificationActivity::class)
        ->addArgument(Mailer::class);
}

The worker will automatically use the container to instantiate activities:

php
// src/Activities/OrderActivity.php
class OrderActivity implements OrderActivityInterface
{
    public function __construct(
        private OrdersTable $ordersTable  // ← Injected via container!
    ) {
    }

    public function createOrder(array $orderData): array
    {
        // Use $this->ordersTable directly
        $order = $this->ordersTable->newEntity($orderData);
        $this->ordersTable->save($order);
        return $order->toArray();
    }
}

Signals and Queries

Signals and queries allow you to interact with running workflows. Signals send data to workflows, while queries read workflow state.

Defining Signals

Define signal methods in your workflow interface:

php
#[WorkflowInterface]
interface OrderApprovalWorkflowInterface
{
    #[WorkflowMethod(name: "order-approval")]
    public function execute(array $data);

    #[SignalMethod]
    public function approve(array $data): void;

    #[SignalMethod]
    public function reject(array $data): void;
}

Implement signals in your workflow:

php
class OrderApprovalWorkflow implements OrderApprovalWorkflowInterface
{
    private bool $approved = false;
    private ?array $rejectionData = null;

    public function execute(array $data)
    {
        // Wait for approval or rejection signal
        yield Workflow::await(
            fn() => $this->approved || $this->rejectionData !== null
        );

        // Process based on signal
        if ($this->rejectionData !== null) {
            return ['status' => 'rejected'];
        }

        return ['status' => 'approved'];
    }

    public function approve(array $data): void
    {
        $this->approved = true;
    }

    public function reject(array $data): void
    {
        $this->rejectionData = $data;
    }
}

Sending Signals

Send signals to running workflows using the workflow client:

php
use Crustum\Temporal\Temporal;
use App\Workflows\OrderApprovalWorkflowInterface;

$client = Temporal::getClient();

// Get stub for running workflow
$workflow = $client->newRunningWorkflowStub(
    OrderApprovalWorkflowInterface::class,
    $workflowId  // The workflow ID you started earlier
);

// Send a signal
$workflow->approve([
    'user_id' => $userId,
    'approved_at' => time(),
    'notes' => 'Approved by manager',
]);

Example from a controller:

php
// src/Controller/ApprovalController.php
public function approve(string $token)
{
    $order = $this->Orders->find()
        ->where(['approval_token' => $token])
        ->first();

    $client = Temporal::getClient();
    $workflow = $client->newRunningWorkflowStub(
        OrderApprovalWorkflowInterface::class,
        $order->workflow_id
    );

    $workflow->approve([
        'user_id' => $this->request->getData('user_id'),
        'approved_at' => time(),
    ]);

    $this->Flash->success('Order approved');
    return $this->redirect(['action' => 'index']);
}

Defining Queries

Define query methods to read workflow state:

php
#[WorkflowInterface]
interface OrderApprovalWorkflowInterface
{
    #[QueryMethod(name: "getStatus")]
    public function getStatus(): array;

    #[QueryMethod(name: "getOrderDetails")]
    public function getOrderDetails(): array;
}

Implement queries in your workflow:

php
class OrderApprovalWorkflow implements OrderApprovalWorkflowInterface
{
    private string $status = 'pending';
    private ?array $orderData = null;

    public function getStatus(): array
    {
        return [
            'status' => $this->status,
            'order_id' => $this->orderData['id'] ?? null,
        ];
    }

    public function getOrderDetails(): array
    {
        return $this->orderData ?? [];
    }
}

Querying Workflows

Query running workflows to get their current state:

php
use Crustum\Temporal\Temporal;
use App\Workflows\OrderApprovalWorkflowInterface;

$client = Temporal::getClient();

$workflow = $client->newRunningWorkflowStub(
    OrderApprovalWorkflowInterface::class,
    $workflowId
);

// Query workflow state
$status = $workflow->getStatus();
$orderDetails = $workflow->getOrderDetails();

Example from a controller:

php
// src/Controller/OrdersController.php
public function status(string $workflowId)
{
    $client = Temporal::getClient();
    $workflow = $client->newRunningWorkflowStub(
        OrderApprovalWorkflowInterface::class,
        $workflowId
    );

    $status = $workflow->getStatus();

    $this->set(compact('status'));
}

Child Workflows

Child workflows allow you to compose complex workflows from simpler ones. Use ChildWorkflowBuilder to start child workflows:

php
use Crustum\Temporal\Temporal;
use App\Workflows\PaymentProcessingWorkflowInterface;
use Temporal\Workflow;

class OrderWorkflow implements OrderWorkflowInterface
{
    public function execute(array $data)
    {
        // Start a child workflow
        $childWorkflow = Temporal::newChildWorkflow()
            ->withWorkflowId('payment-' . $data['order_id'])
            ->withTaskQueue('payment-queue')
            ->build(PaymentProcessingWorkflowInterface::class);

        $result = yield $childWorkflow->execute($data);

        return $result;
    }
}

Child workflows support the same configuration options as regular workflows:

php
$childWorkflow = Temporal::newChildWorkflow()
    ->withWorkflowId('child-' . uniqid())
    ->withTaskQueue('child-queue')
    ->withWorkflowExecutionTimeout(new \DateInterval('PT30M'))
    ->withRetryOptions(RetryOptions::new()->withMaximumAttempts(3))
    ->build(ChildWorkflowInterface::class);

Local Activities

Local activities execute in the same worker process as the workflow, providing lower latency but less isolation. Use LocalActivityBuilder for local activities:

php
use Crustum\Temporal\Temporal;
use App\Activities\ValidationActivityInterface;
use Temporal\Workflow;

class OrderWorkflow implements OrderWorkflowInterface
{
    public function execute(array $data)
    {
        // Local activity - runs in same worker process
        $validationActivity = Temporal::newLocalActivity()
            ->withStartToCloseTimeout(new \DateInterval('PT5S'))
            ->build(ValidationActivityInterface::class);

        $isValid = yield $validationActivity->validate($data);

        if (!$isValid) {
            throw new \Exception('Invalid order data');
        }

        // Continue with regular activities...
    }
}

Local activities are useful for fast, local operations that don't need isolation.

Interceptors

Interceptors allow you to add cross-cutting concerns to workflows and activities, such as logging, metrics, or error handling.

Generating Interceptors

Generate an interceptor using the bake command:

bash
# Interactive mode - select types from menu
bin/cake bake temporal interceptor Logging

# Or specify types directly
bin/cake bake temporal interceptor Logging --type=workflow_inbound_calls,activity_inbound

# Multiple types
bin/cake bake temporal interceptor Metrics --type=workflow_client_calls,activity_inbound,grpc_client

Available interceptor types:

  • workflow_client_calls - Intercept workflow client calls (start, signal, query, etc.)
  • workflow_inbound_calls - Intercept workflow inbound calls (execute, handleSignal, handleQuery, etc.)
  • workflow_outbound_calls - Intercept workflow outbound calls (executeActivity, executeChildWorkflow, etc.)
  • activity_inbound - Intercept activity inbound calls (handleActivityInbound)
  • grpc_client - Intercept gRPC client calls
  • workflow_outbound_request - Intercept workflow outbound requests to RoadRunner

Defining Interceptors

The generated interceptor will implement the appropriate interfaces:

php
<?php
declare(strict_types=1);

namespace App\Interceptors;

use Temporal\Interceptor\Trait\ActivityInboundInterceptorTrait;
use Temporal\Interceptor\Trait\WorkflowInboundCallsInterceptorTrait;
use Temporal\Interceptor\ActivityInboundInterceptor;
use Temporal\Interceptor\WorkflowInboundCallsInterceptor;

class LoggingInterceptor implements
    WorkflowInboundCallsInterceptor,
    ActivityInboundInterceptor
{
    use WorkflowInboundCallsInterceptorTrait;
    use ActivityInboundInterceptorTrait;

    public function executeActivity(
        \Temporal\Interceptor\ActivityInbound\ExecuteActivityInput $input,
        callable $next
    ) {
        \Cake\Log\Log::info('Executing activity', [
            'activity' => $input->activityType,
        ]);

        try {
            $result = $next($input);
            \Cake\Log\Log::info('Activity completed', [
                'activity' => $input->activityType,
            ]);
            return $result;
        } catch (\Throwable $e) {
            \Cake\Log\Log::error('Activity failed', [
                'activity' => $input->activityType,
                'error' => $e->getMessage(),
            ]);
            throw $e;
        }
    }
}

Registering Interceptors

Register interceptors in your config/temporal.php:

php
return [
    'Temporal' => [
        // ...
        'interceptors' => [
            \App\Interceptors\LoggingInterceptor::class,
            \App\Interceptors\MetricsInterceptor::class,
        ],
    ],
];

Discovery

The plugin can automatically discover and register workflows and activities from configured paths.

Auto-Discovery

Configure discovery paths in config/temporal.php:

php
return [
    'Temporal' => [
        'discovery' => [
            'workflows' => [
                'src/Workflows',
                'plugins/MyPlugin/src/Workflows',  // Plugin workflows
            ],
            'activities' => [
                'src/Activities',
                'plugins/MyPlugin/src/Activities',  // Plugin activities
            ],
        ],
    ],
];

The plugin will automatically discover and register:

  • Workflow classes implementing WorkflowInterface
  • Activity classes implementing ActivityInterface

Paths can be:

  • Relative to ROOT (e.g., src/Workflows)
  • Absolute paths (e.g., /var/www/workflows)

Manual Registration

You can also manually register workflows and activities:

php
use Crustum\Temporal\Temporal;

$registry = Temporal::getRegistry();

$registry->registerWorkflows(
    \App\Workflows\OrderWorkflow::class,
    \App\Workflows\PaymentWorkflow::class
);

$registry->registerActivities(
    \App\Activities\OrderActivity::class,
    \App\Activities\PaymentActivity::class
);

Worker Management

Workers execute workflows and activities. The plugin provides a command to start and manage workers.

Starting Workers

Start a worker using the temporal work command:

bash
bin/cake temporal work

This will:

  1. Discover all registered workflows and activities
  2. Start a RoadRunner worker
  3. Connect to the Temporal server
  4. Begin processing tasks from the configured queue

Custom Queue

Start a worker for a specific queue:

bash
bin/cake temporal work custom-queue

Watch Mode

Enable auto-reload on file changes:

bash
bin/cake temporal work --watch

Advanced Worker Options

The temporal work command supports several advanced options:

bash
# Custom RoadRunner config
bin/cake temporal work --rr-config=/path/to/.rr.yaml

# Custom RPC host and port
bin/cake temporal work --rpc-host=127.0.0.1 --rpc-port=6001

# Set number of workers
bin/cake temporal work --workers=4

# Set max jobs before reload
bin/cake temporal work --max-jobs=500

# Watch mode with auto-reload
bin/cake temporal work --watch

Available Options:

  • --rr-config: Path to custom RoadRunner configuration file
  • --rpc-host: RPC host for server communication (default: 127.0.0.1)
  • --rpc-port: RPC port for server communication (default: 6001)
  • --workers: Number of workers to start (default: auto)
  • --max-jobs: Maximum jobs to process before reloading (default: 500)
  • --watch: Enable auto-reload on file changes

Worker Configuration

The worker uses RoadRunner for execution. Create a .rr.yaml file in your project root:

yaml
version: "3"

server:
  command: "php plugins/Temporal/bin/roadrunner-worker.php"

temporal:
  address: localhost:7233
  namespace: default

logs:
  mode: production
  level: info
  output: stdout
  encoding: json

The temporal work command will automatically override configuration values from config/temporal.php.

RoadRunner Integration

The plugin automatically:

  • Configures RoadRunner with Temporal settings
  • Registers workflows and activities
  • Handles dependency injection for activities
  • Manages server state and process reloading

The worker script (bin/roadrunner-worker.php) handles:

  • Application bootstrapping
  • Container setup
  • Workflow and activity registration
  • Discovery fallback
  • Interceptor registration

Note: The RoadRunner binary should be installed using bin/cake temporal install before running workers.

Cross-Language Activities

Temporal supports calling activities written in different languages. You can call Go, Java, Python, or other language activities from PHP workflows.

Go Activities

To call a Go activity, use buildUntyped() instead of build():

php
use Crustum\Temporal\Temporal;
use Temporal\Workflow;

class OrderWorkflow implements OrderWorkflowInterface
{
    public function execute(array $data)
    {
        // Call Go activity using untyped stub
        $goPaymentActivity = Temporal::newActivity()
            ->withTaskQueue('go-payment-queue')  // Go worker's queue
            ->withStartToCloseTimeout(new \DateInterval('PT30S'))
            ->buildUntyped();  // ← Use buildUntyped() for Go activities

        // Execute Go activity by name
        $paymentResult = yield $goPaymentActivity->execute(
            'payment.ProcessPayment',  // Go activity name (string)
            [(string)$orderId, (float)$data['total_amount']]  // Arguments
        );

        // Convert result (Go returns stdClass, convert to array)
        if (is_object($paymentResult)) {
            $paymentResult = json_decode(json_encode($paymentResult), true);
        }

        return $paymentResult;
    }
}

Activity Name Matching

The activity name in PHP must match the registered name in Go:

PHP:

php
$goActivity->execute('payment.ProcessPayment', $args);

Go:

go
w.RegisterActivityWithOptions(ProcessPaymentActivity, activity.RegisterOptions{
    Name: "payment.ProcessPayment",  // ← Must match PHP call
})

Data Serialization

Temporal automatically serializes data between languages using JSON. Ensure your data types are compatible:

  • PHP arrays → Go map[string]interface{}
  • PHP strings → Go string
  • PHP floats → Go float64
  • PHP integers → Go int64

Go activities should return map[string]interface{}:

go
func ProcessPaymentActivity(ctx context.Context, orderID string, amount float64) (map[string]interface{}, error) {
    return map[string]interface{}{
        "success": true,
        "payment_id": "pay_123",
    }, nil
}

Data Converters

Temporal uses data converters to serialize and deserialize data. The plugin includes a custom converter for CakePHP entities.

CakePHP Entity Serialization

The CakePayloadConverter automatically handles CakePHP entities:

php
use App\Model\Entity\Order;
use Crustum\Temporal\Temporal;

// In a workflow
$order = new Order(['id' => 1, 'total' => 99.99]);

// Entity is automatically serialized
$activity = Temporal::newActivity()->build(OrderActivityInterface::class);
yield $activity->processOrder($order);  // Entity passed directly

// Entity is automatically deserialized in activity
class OrderActivity implements OrderActivityInterface
{
    public function processOrder(Order $order): array
    {
        // $order is a fully reconstructed Entity instance
        return $order->toArray();
    }
}

The converter handles:

  • Simple entities
  • Nested entities (hasOne, belongsTo)
  • Entity collections (hasMany, belongsToMany)
  • Entity properties and relations

Custom Converters

You can register a custom data converter:

php
// In Application::services() or bootstrap
use Crustum\Temporal\Temporal;
use Temporal\DataConverter\DataConverter;
use App\DataConverter\CustomConverter;

$converter = new DataConverter(
    new NullConverter(),
    new BinaryConverter(),
    new ProtoJsonConverter(),
    new ProtoConverter(),
    new CustomConverter()  // Your custom converter
);

Temporal::setConfig('data_converter', $converter);

Testing

The plugin provides comprehensive testing infrastructure for workflows and activities, including both mocking capabilities and full integration testing with a real Temporal server.

Testing Infrastructure

The plugin includes:

  • PHPUnit Extension - Automatically starts/stops Temporal server and worker for integration tests
  • WithTemporalTrait - Trait-based setup for per-test-case server management
  • TemporalMocker - Mocking system for workflows and activities
  • LocalTemporalServer - Local Temporal server for testing
  • TimeSkippingTemporalServer - Server with time manipulation for testing timeouts and delays

Testing Activities

Test activities independently:

php
<?php
namespace App\Test\TestCase\Activity;

use App\Activities\OrderActivity;
use App\Model\Table\OrdersTable;
use Cake\TestSuite\TestCase;

class OrderActivityTest extends TestCase
{
    public function testCreateOrder(): void
    {
        $ordersTable = $this->getTableLocator()->get('Orders');
        $activity = new OrderActivity($ordersTable);

        $result = $activity->createOrder([
            'user_id' => 1,
            'total' => 99.99,
        ]);

        $this->assertArrayHasKey('id', $result);
        $this->assertEquals('pending', $result['status']);
    }
}

Testing Workflows

Test workflows using the mocking system:

php
<?php
namespace App\Test\TestCase\Workflow;

use App\Workflows\OrderWorkflowInterface;
use App\Activities\OrderActivityInterface;
use Crustum\Temporal\Temporal;
use Crustum\Temporal\TestSuite\WithTemporalTrait;
use Cake\TestSuite\TestCase;

class OrderWorkflowTest extends TestCase
{
    use WithTemporalTrait;

    protected function setUp(): void
    {
        parent::setUp();
        $this->setUpWithTemporal();
    }

    public function testOrderProcessing(): void
    {
        Temporal::fake();

        // Mock activity
        Temporal::mockActivity([OrderActivityInterface::class, 'createOrder'])
            ->andReturn(['id' => 1, 'status' => 'created']);

        // Start workflow
        $workflow = Temporal::newWorkflow()
            ->build(OrderWorkflowInterface::class);

        $result = $workflow->execute(['user_id' => 1, 'total' => 99.99]);

        // Assertions
        $this->assertEquals('completed', $result['status']);
        Temporal::assertActivityDispatched([OrderActivityInterface::class, 'createOrder']);
        Temporal::assertActivityDispatchedTimes([OrderActivityInterface::class, 'createOrder'], 1);
    }
}

Mock Builders

The plugin provides fluent mock builders:

php
use Crustum\Temporal\Temporal;
use Crustum\Temporal\TestSuite\WithTemporalTrait;
use App\Workflows\OrderWorkflowInterface;
use App\Activities\OrderActivityInterface;

class OrderWorkflowTest extends TestCase
{
    use WithTemporalTrait;

    protected function setUp(): void
    {
        parent::setUp();
        $this->setUpWithTemporal();
    }

    public function testWorkflowWithMockedActivity(): void
    {
        Temporal::fake();

        // Mock workflow
        Temporal::mockWorkflow(OrderWorkflowInterface::class)
            ->andReturn(['status' => 'completed']);

        // Or mock activity
        Temporal::mockActivity([OrderActivityInterface::class, 'createOrder'])
            ->andReturn(['id' => 1, 'status' => 'created']);

        // Test code that uses workflows/activities...

        // Assertions
        Temporal::assertWorkflowDispatched(OrderWorkflowInterface::class);
        Temporal::assertWorkflowDispatchedTimes(OrderWorkflowInterface::class, 1);

        Temporal::assertActivityDispatched([OrderActivityInterface::class, 'createOrder'], function ($orderData) {
            return $orderData['user_id'] === 1;
        });
    }
}

PHPUnit Extension

For integration tests, use the PHPUnit extension to automatically start/stop the Temporal server:

1. Configure PHPUnit (phpunit.xml):

xml
<extensions>
    <bootstrap class="Crustum\Temporal\TestSuite\Extension\TemporalExtension">
        <parameter name="time_skipping" value="false"/>
        <parameter name="debug" value="false"/>
        <parameter name="start_server" value="true"/>
    </bootstrap>
</extensions>

<php>
    <env name="TEMPORAL_CONFIG" value="config/temporal.php"/>
</php>

2. Write Integration Tests:

php
use Crustum\Temporal\TestSuite\WithTemporalTrait;

class OrderWorkflowIntegrationTest extends TestCase
{
    use WithTemporalTrait;

    protected function setUp(): void
    {
        parent::setUp();
        // Extension automatically starts server, just set up worker
        $this->setUpWithTemporal();
    }

    public function testRealWorkflowExecution(): void
    {
        // Test with real Temporal server and worker
        $workflow = Temporal::newWorkflow()
            ->build(OrderWorkflowInterface::class);

        $result = $workflow->execute(['user_id' => 1, 'total' => 99.99]);
        $this->assertArrayHasKey('status', $result);
    }
}

Testing Configuration

Configure testing behavior in config/temporal.php:

php
'testing' => [
    'server' => true,              // Start Temporal server in tests
    'time_skipping' => false,      // Enable time skipping
    'debug' => false,              // Enable debug output
    'address' => 'localhost:7233', // Server address
    'namespace' => 'default',      // Namespace
],

Or via environment variables:

ini
TEMPORAL_TESTING_SERVER=true
TEMPORAL_TESTING_TIME_SKIPPING=false
TEMPORAL_TESTING_DEBUG=false

Advanced Topics

Retry Policies

Configure retry policies in config/temporal.php:

php
'retry' => [
    'workflow' => [
        'initial_interval' => 1,        // Initial retry interval in seconds
        'backoff_coefficient' => 2.0,   // Exponential backoff multiplier
        'maximum_interval' => 100,       // Maximum retry interval
        'maximum_attempts' => 0,        // 0 = unlimited retries
    ],
    'activity' => [
        'initial_interval' => 1,
        'backoff_coefficient' => 2.0,
        'maximum_interval' => 100,
        'maximum_attempts' => 3,        // Retry up to 3 times
    ],
],

Override retry policies per workflow or activity:

php
use Temporal\Common\RetryOptions;

$workflow = Temporal::newWorkflow()
    ->withRetryOptions(
        RetryOptions::new()
            ->withInitialInterval(new \DateInterval('PT5S'))
            ->withBackoffCoefficient(1.5)
            ->withMaximumAttempts(5)
    )
    ->build(WorkflowInterface::class);

Workflow Timeouts

Configure workflow timeouts:

php
$workflow = Temporal::newWorkflow()
    ->withWorkflowExecutionTimeout(new \DateInterval('PT1H'))  // 1 hour max execution
    ->withWorkflowRunTimeout(new \DateInterval('PT30M'))      // 30 minutes max run
    ->withWorkflowTaskTimeout(new \DateInterval('PT10S'))     // 10 seconds task timeout
    ->build(WorkflowInterface::class);

Activity Timeouts

Configure activity timeouts:

php
$activity = Temporal::newActivity()
    ->withStartToCloseTimeout(new \DateInterval('PT30S'))      // 30 seconds execution
    ->withScheduleToCloseTimeout(new \DateInterval('PT1M'))   // 1 minute total
    ->withScheduleToStartTimeout(new \DateInterval('PT10S'))  // 10 seconds to start
    ->withHeartbeatTimeout(new \DateInterval('PT5S'))         // 5 seconds heartbeat
    ->build(ActivityInterface::class);

Task Queues

Task queues route work to specific workers. Use different queues for different workloads:

php
// High-priority queue
$workflow = Temporal::newWorkflow()
    ->withTaskQueue('high-priority-queue')
    ->build(WorkflowInterface::class);

// Background processing queue
$activity = Temporal::newActivity()
    ->withTaskQueue('background-queue')
    ->build(ActivityInterface::class);

Start workers for specific queues:

bash
bin/cake temporal work high-priority-queue
bin/cake temporal work background-queue

Workflow IDs

Workflow IDs must be unique within a namespace. Use meaningful IDs:

php
$workflow = Temporal::newWorkflow()
    ->withWorkflowId('order-' . $orderId)
    ->build(OrderWorkflowInterface::class);

For idempotency, use deterministic IDs:

php
$workflowId = 'order-processing-' . md5(json_encode($orderData));
$workflow = Temporal::newWorkflow()
    ->withWorkflowId($workflowId)
    ->build(OrderWorkflowInterface::class);

Commands

The plugin provides several console commands for managing Temporal workflows, activities, and workers.

Install Command

The temporal install command sets up Temporal worker dependencies:

bash
bin/cake temporal install

This command:

  • Downloads RoadRunner binary to bin/rr (or bin/rr.exe on Windows)
  • Installs file watcher dependencies (npm install in bin/watch/)
  • Updates .gitignore to exclude RoadRunner binaries and configuration files

What it does:

  • Checks for RoadRunner binary in bin/, project root, environment variables, and system PATH
  • Downloads binary if not found using vendor/bin/rr get-binary
  • Installs chokidar package for file watching (if package.json exists in bin/watch/)
  • Adds bin/rr, bin/rr.exe, and .rr.yaml to .gitignore

Work Command

The temporal work command starts a Temporal worker:

bash
# Basic usage
bin/cake temporal work

# Custom queue
bin/cake temporal work custom-queue

# With options
bin/cake temporal work --watch --workers=4 --max-jobs=500

Available Options:

  • --rpc-host: RPC host for server communication (default: 127.0.0.1)
  • --rpc-port: RPC port for server communication (default: 6001)
  • --workers: Number of workers to start (default: auto)
  • --max-jobs: Maximum jobs to process before reloading (default: 500)
  • --rr-config: Path to custom RoadRunner configuration file
  • --watch: Enable auto-reload on file changes

Bake Commands

The plugin provides bake commands for generating Temporal classes:

Generate Workflow:

bash
bin/cake bake temporal workflow OrderProcessing

Generate Activity:

bash
bin/cake bake temporal activity OrderCreation

Generate Interceptor:

bash
# Interactive mode
bin/cake bake temporal interceptor Logging

# With types specified
bin/cake bake temporal interceptor Logging --type=workflow_inbound_calls,activity_inbound

All bake commands support the --force flag to overwrite existing files.

PHPStan Integration

The plugin includes PHPStan extensions for better static analysis of Temporal code. These extensions provide proper type inference for workflow proxies, activity proxies, and client interfaces.

PHPStan Extensions

The plugin provides the following PHPStan extensions:

  1. TemporalActivityProxyExtension - Type inference for activity proxy methods
  2. TemporalWorkflowProxyExtension - Type inference for workflow proxy methods
  3. TemporalChildWorkflowProxyExtension - Type inference for child workflow proxy methods
  4. TemporalWorkflowClientInterfaceExtension - Type inference for WorkflowClientInterface methods
  5. TemporalWorkflowContextInterfaceExtension - Type inference for WorkflowContextInterface methods

Configuration

The extensions are automatically registered when you include the plugin's PHPStan configuration:

neon
# phpstan.neon
includes:
    - vendor/crustum/temporal/extension.neon

Or if using the plugin's composer.json extra configuration:

json
{
    "extra": {
        "phpstan": {
            "includes": [
                "extension.neon"
            ]
        }
    }
}

Benefits

With PHPStan extensions enabled, you get:

  • Proper type inference for workflow and activity method calls
  • Type safety for workflow client operations
  • Better IDE support with accurate autocomplete
  • Static analysis catches type errors before runtime

Example:

php
use App\Workflows\OrderWorkflowInterface;

$workflow = Temporal::newWorkflow()
    ->build(OrderWorkflowInterface::class);

// PHPStan knows the exact return type from the interface
$result = $workflow->execute(['user_id' => 1]);
// PHPStan infers the correct return type

Released under the MIT License.