symfony-opcua · master
Docs · Operations

Method calls

Calling OPC UA methods from Symfony — recipe loads, alarm acknowledgements, lifecycle transitions. The right tool when a single write isn't enough.

OPC UA method nodes are functions the server exposes. They take typed input arguments, return typed outputs, and run atomically server-side. The right tool when a single write() isn't enough — recipe loads, batch acknowledgements, lifecycle transitions.

Anatomy of a call

A method call needs:

  1. The object — the parent node.
  2. The method — the method node itself.
  3. Input arguments — matching the method's InputArguments.

Returns a CallResult object with:

  • $statusCode (int — 0 means Good).
  • $outputArguments (array<int, mixed> — the typed outputs).

The real API — call()

The method is named call() (not callMethod). The signature is positional:

public function call(
    NodeId|string $objectId,
    NodeId|string $methodId,
    array $inputArguments = [],
): CallResult;

There is no callBuilder() / callMethod() method, and the return value is not a [int, array] tuple — it is a CallResult object.

Basic call

php basic call
use PhpOpcua\Client\OpcUaClientInterface;

final class RecipeService
{
    public function __construct(private OpcUaClientInterface $client) {}

    public function load(string $recipeName, int $line): bool
    {
        $result = $this->client->call(
            'ns=2;s=Recipe',                 // object node
            'ns=2;s=Recipe.Load',            // method node
            [$recipeName, $line],            // input arguments
        );

        if ($result->statusCode !== 0) {
            throw new \RuntimeException(
                sprintf('Recipe load failed: 0x%X', $result->statusCode)
            );
        }

        return (bool) ($result->outputArguments[0] ?? false);
    }
}

Explicit-typed inputs

For arguments where you must pin the OPC UA type explicitly, wrap them in a typed Variant (or pass them via a DataValue your value-coercion layer accepts):

use PhpOpcua\Client\Types\BuiltinType;
use PhpOpcua\Client\Types\Variant;

$result = $this->client->call(
    'ns=2;s=Recipe',
    'ns=2;s=Recipe.Load',
    [
        new Variant($recipeName, BuiltinType::String),
        new Variant($line,        BuiltinType::UInt32),
    ],
);

Discovering signatures

Method nodes carry InputArguments and OutputArguments property nodes:

$inputs = $this->client->read('ns=2;s=Recipe.Load.InputArguments')->getValue();
foreach ($inputs as $arg) {
    echo "{$arg->name} : {$arg->dataType} ({$arg->description})\n";
}

Always read signatures when integrating with a new method — argument naming and ordering vary by server.

Alarm acknowledgement — Notifier integration

A common UI flow: operator clicks "Acknowledge" → Symfony Notifier broadcasts → method call confirms server-side.

php src/Service/AlarmAcknowledgeService.php
namespace App\Service;

use PhpOpcua\Client\OpcUaClientInterface;
use PhpOpcua\Client\Types\BuiltinType;
use PhpOpcua\Client\Types\Variant;

final class AlarmAcknowledgeService
{
    public function __construct(private OpcUaClientInterface $client) {}

    public function acknowledge(string $eventIdHex, string $comment): bool
    {
        $eventId = hex2bin($eventIdHex);

        $result = $this->client->call(
            'ns=0;i=2782',                                                   // ConditionType
            'ns=0;i=9111',                                                   // Acknowledge
            [
                new Variant($eventId, BuiltinType::ByteString),
                ['locale' => 'en', 'text' => $comment],                       // LocalizedText
            ],
        );

        if ($result->statusCode !== 0) {
            throw new \RuntimeException(
                sprintf('Ack failed: 0x%X', $result->statusCode)
            );
        }
        return true;
    }
}

In the controller:

php controller
#[Route('/api/alarms/{eventId}/ack', methods: ['POST'])]
public function ack(
    string $eventId,
    Request $request,
    AlarmAcknowledgeService $svc,
): JsonResponse {
    $this->denyAccessUnlessGranted('alarm.ack');

    $comment = (string) (json_decode($request->getContent(), true)['comment'] ?? '');
    $svc->acknowledge($eventId, $comment);

    return $this->json(['status' => 'acked']);
}

See Recipes · Alarm routing.

When status != 0

Status code Likely cause
Bad_NodeIdInvalid Wrong method node ID
Bad_MethodInvalid Method exists but invalid in this calling context
Bad_ArgumentsMissing Fewer inputs than the method expects
Bad_TypeMismatch An input's type doesn't match InputArguments
Bad_OutOfRange Input value out of range
Bad_UserAccessDenied Session lacks permission
Bad_NotExecutable Method's Executable attribute is false

A non-zero status from call() returns inside the CallResult — the SDK will not raise unless the entire service fails (in which case you get a ServiceException). Catch the latter; check $result->statusCode for per-call failures.

Read first, then call

For state-dependent methods:

php state-gated
$state = $this->client->read('ns=2;s=Line.State')->getValue();
if ($state !== 'Standby') {
    throw new \DomainException("Can't load recipe while line is $state");
}

$this->client->call(
    'ns=2;s=Recipe',
    'ns=2;s=Recipe.Load',
    ['NewRecipe'],
);

This pair is not atomic — the state could change between the read and the call. Accept the race or use a Symfony Lock to serialise:

php serialised with Lock
use Symfony\Component\Lock\LockFactory;

public function load(string $recipeName, LockFactory $locks): void
{
    $lock = $locks->createLock('plc-recipe-load', ttl: 60);
    $lock->acquire(blocking: true);

    try {
        $state = $this->client->read('ns=2;s=Line.State')->getValue();
        if ($state !== 'Standby') {
            throw new \DomainException("State is $state");
        }
        $this->client->call(
            'ns=2;s=Recipe',
            'ns=2;s=Recipe.Load',
            [$recipeName],
        );
    } finally {
        $lock->release();
    }
}

See Symfony Lock documentation.

Concurrency at the device

Many PLCs serialise method execution device-side. Two concurrent Recipe.Load calls don't run in parallel — the second waits or returns Bad_ResourceBusy. Test the device's actual behaviour before optimising.

In Messenger handlers

For long-running operations (recipe loads can take seconds), dispatch via Messenger:

php src/Message/LoadRecipe.php
namespace App\Message;

final readonly class LoadRecipe
{
    public function __construct(
        public string $recipeName,
        public int $lineId,
    ) {}
}
php src/MessageHandler/LoadRecipeHandler.php
namespace App\MessageHandler;

use App\Message\LoadRecipe;
use PhpOpcua\Client\OpcUaClientInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class LoadRecipeHandler
{
    public function __construct(private OpcUaClientInterface $client) {}

    public function __invoke(LoadRecipe $message): void
    {
        $result = $this->client->call(
            'ns=2;s=Recipe',
            'ns=2;s=Recipe.Load',
            [$message->recipeName, $message->lineId],
        );

        if ($result->statusCode !== 0) {
            throw new \RuntimeException(sprintf('Recipe load failed: 0x%X', $result->statusCode));
        }
    }
}

Don't retry the message — method calls aren't idempotent. Use #[AsMessageHandler] with WithMonologChannel or a per-handler retry strategy of 1.

Browsing for methods

Discover the methods on an object:

use PhpOpcua\Client\Types\BrowseDirection;
use PhpOpcua\Client\Types\NodeClass;

$methods = $this->client->browse(
    'ns=2;s=Recipe',
    BrowseDirection::Forward,
    true,
    NodeClass::Method->value,
);

For each, read InputArguments / OutputArguments to build a complete signature map.

Documentation