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:
- The object — the parent node.
- The method — the method node itself.
- Input arguments — matching the method's
InputArguments.
Returns a CallResult object with:
$statusCode(int —0means 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
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.
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:
#[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']);
}
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:
$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:
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:
namespace App\Message;
final readonly class LoadRecipe
{
public function __construct(
public string $recipeName,
public int $lineId,
) {}
}
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.
Where to read next
- Subscriptions — react to method-induced state changes.
- Recipes · Alarm routing — full pipeline with the Notifier component.