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:
composer require crustum/temporalRequirements
- 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:
docker run -p 7233:7233 temporalio/auto-setup:latestOr install the Temporal CLI and run the development server:
# Install Temporal CLI
# See: https://docs.temporal.io/cli
# Start development server
temporal server start-devInstalling Plugin Assets
After installing the plugin via Composer, you need to install plugin assets (configuration files, worker scripts, etc.):
Step 1: Using PluginManifest (Recommended)
bin/cake manifest install --plugin TemporalThis will:
- Copy
config/temporal.phpto your application - Copy
bin/roadrunner-worker.phpto your application - Copy file watcher scripts to
bin/watch/ - Add configuration loading to
config/bootstrap.php
Step 2: Using Temporal Install Command
bin/cake temporal installThis will:
- Download RoadRunner binary to
bin/rr(orbin/rr.exeon Windows) - Install file watcher dependencies (
npm installinbin/watch/) - Update
.gitignoreto 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:
composer require spiral/roadrunner-cli --dev
./vendor/bin/rr get-binaryQuickstart
1. Load the Plugin
First, load the Temporal plugin in your Application.php:
// In src/Application.php
public function bootstrap(): void
{
parent::bootstrap();
$this->addPlugin('Crustum/Temporal');
}2. Install Plugin Assets
Install plugin assets using PluginManifest:
bin/cake manifest install --plugin TemporalThis 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:
bin/cake bake temporal workflow OrderProcessingThis will create:
src/Workflows/OrderProcessingWorkflowInterface.phpsrc/Workflows/OrderProcessingWorkflow.php
4. Generate an Activity
Generate an activity to handle order creation:
bin/cake bake temporal activity OrderCreationThis will create:
src/Activities/OrderCreationActivityInterface.phpsrc/Activities/OrderCreationActivity.php
5. Implement Your Workflow
// 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
// 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():
// 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:
// 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:
bin/cake temporal workYour workflow will now execute automatically!
Configuration
All of your application's Temporal configuration is stored in the config/temporal.php configuration file:
<?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
--watchflag) - 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
- server: Whether to start Temporal server in tests (default:
Environment Variables
You can override configuration values using environment variables:
TEMPORAL_ADDRESS=localhost:7233
TEMPORAL_NAMESPACE=production
TEMPORAL_QUEUE=my-queueWorkflows
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:
bin/cake bake temporal workflow OrderApprovalThis command will create:
src/Workflows/OrderApprovalWorkflowInterface.php- The workflow interfacesrc/Workflows/OrderApprovalWorkflow.php- The workflow implementation
The generated workflow includes:
- A
WorkflowInterfaceattribute - A
WorkflowMethodattribute on theexecute()method - Commented-out examples for
QueryMethodandSignalMethod
Defining Workflows
A workflow consists of an interface and an implementation. The interface defines the workflow's contract:
<?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
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]:
#[WorkflowMethod(name: "order-approval")]
public function execute(array $data);Query Methods
Allow reading workflow state without affecting execution. Marked with #[QueryMethod]:
#[QueryMethod(name: "getStatus")]
public function getStatus(): array;Signal Methods
Allow sending data to a running workflow. Marked with #[SignalMethod]:
#[SignalMethod]
public function approve(array $data): void;Starting Workflows
You can start workflows from controllers, commands, or anywhere in your application:
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:
$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 IDwithTaskQueue(string $queue)- Set the task queuewithWorkflowExecutionTimeout(DateInterval $timeout)- Maximum execution timewithWorkflowRunTimeout(DateInterval $timeout)- Maximum run timewithWorkflowTaskTimeout(DateInterval $timeout)- Task processing timeoutwithMemo(array $memo)- Add metadatawithSearchAttributes(array $attributes)- Add searchable attributeswithRetryOptions(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:
bin/cake bake temporal activity PaymentProcessingThis creates:
src/Activities/PaymentProcessingActivityInterface.phpsrc/Activities/PaymentProcessingActivity.php
Defining Activities
An activity consists of an interface and an implementation:
<?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
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]:
#[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:
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:
$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 queuewithStartToCloseTimeout(DateInterval $timeout)- Maximum execution timewithScheduleToCloseTimeout(DateInterval $timeout)- Maximum time from schedule to completionwithScheduleToStartTimeout(DateInterval $timeout)- Maximum time from schedule to startwithHeartbeatTimeout(DateInterval $timeout)- Heartbeat timeoutwithRetryOptions(RetryOptions $options)- Configure retry policywithActivityId(string $id)- Set a custom activity ID
Dependency Injection
Activities can receive dependencies via constructor injection. Register activities in your Application::services() method:
// 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:
// 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:
#[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:
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:
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:
// 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:
#[WorkflowInterface]
interface OrderApprovalWorkflowInterface
{
#[QueryMethod(name: "getStatus")]
public function getStatus(): array;
#[QueryMethod(name: "getOrderDetails")]
public function getOrderDetails(): array;
}Implement queries in your workflow:
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:
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:
// 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:
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:
$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:
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:
# 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_clientAvailable 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 callsworkflow_outbound_request- Intercept workflow outbound requests to RoadRunner
Defining Interceptors
The generated interceptor will implement the appropriate interfaces:
<?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:
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:
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:
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:
bin/cake temporal workThis will:
- Discover all registered workflows and activities
- Start a RoadRunner worker
- Connect to the Temporal server
- Begin processing tasks from the configured queue
Custom Queue
Start a worker for a specific queue:
bin/cake temporal work custom-queueWatch Mode
Enable auto-reload on file changes:
bin/cake temporal work --watchAdvanced Worker Options
The temporal work command supports several advanced options:
# 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 --watchAvailable 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:
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: jsonThe 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():
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:
$goActivity->execute('payment.ProcessPayment', $args);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{}:
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:
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:
// 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
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
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:
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):
<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:
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:
'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:
TEMPORAL_TESTING_SERVER=true
TEMPORAL_TESTING_TIME_SKIPPING=false
TEMPORAL_TESTING_DEBUG=falseAdvanced Topics
Retry Policies
Configure retry policies in config/temporal.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:
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:
$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:
$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:
// 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:
bin/cake temporal work high-priority-queue
bin/cake temporal work background-queueWorkflow IDs
Workflow IDs must be unique within a namespace. Use meaningful IDs:
$workflow = Temporal::newWorkflow()
->withWorkflowId('order-' . $orderId)
->build(OrderWorkflowInterface::class);For idempotency, use deterministic IDs:
$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:
bin/cake temporal installThis command:
- Downloads RoadRunner binary to
bin/rr(orbin/rr.exeon Windows) - Installs file watcher dependencies (
npm installinbin/watch/) - Updates
.gitignoreto 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
chokidarpackage for file watching (ifpackage.jsonexists inbin/watch/) - Adds
bin/rr,bin/rr.exe, and.rr.yamlto.gitignore
Work Command
The temporal work command starts a Temporal worker:
# 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=500Available 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:
bin/cake bake temporal workflow OrderProcessingGenerate Activity:
bin/cake bake temporal activity OrderCreationGenerate Interceptor:
# Interactive mode
bin/cake bake temporal interceptor Logging
# With types specified
bin/cake bake temporal interceptor Logging --type=workflow_inbound_calls,activity_inboundAll 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:
- TemporalActivityProxyExtension - Type inference for activity proxy methods
- TemporalWorkflowProxyExtension - Type inference for workflow proxy methods
- TemporalChildWorkflowProxyExtension - Type inference for child workflow proxy methods
- TemporalWorkflowClientInterfaceExtension - Type inference for
WorkflowClientInterfacemethods - TemporalWorkflowContextInterfaceExtension - Type inference for
WorkflowContextInterfacemethods
Configuration
The extensions are automatically registered when you include the plugin's PHPStan configuration:
# phpstan.neon
includes:
- vendor/crustum/temporal/extension.neonOr if using the plugin's composer.json extra configuration:
{
"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:
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