symfony-opcua · v4.3.x
Docs · Operations

Writing

Writes mutate physical equipment. Type detection, explicit types, batch writes, the authorisation/audit/rate-limit patterns Symfony apps reach for.

$client->write($node, $value) sends a value to a writable OPC UA node.

Note

Writes mutate physical equipment. Treat them with the same care as DELETE FROM in SQL. Authorisation, audit, rate-limiting belong at the application layer — Symfony has all three out of the box.

Basic write

php write
use PhpOpcua\Client\OpcUaClientInterface;

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

    public function setSpeed(float $rpm): void
    {
        $this->client->write('ns=2;s=Setpoint', $rpm);
    }
}

write() returns an int OPC UA status code (0 is Good). The library does not raise on a non-Good return from the server — check the returned status. Transport-level failures do raise:

  • ConnectionException — TCP / handshake failure.
  • ServiceException — server returned Bad_* at the service layer (e.g. Bad_NodeIdInvalid); the operation-level status is still surfaced through the return value for per-node reasons (e.g. Bad_TypeMismatch, Bad_NotWritable, Bad_UserAccessDenied).
$status = $this->client->write('ns=2;s=Setpoint', $rpm);
if ($status !== 0) {
    throw new \RuntimeException(
        'Write failed: ' . \PhpOpcua\Client\Types\StatusCode::getName($status)
    );
}

Type detection

The client infers BuiltinType from the PHP value:

PHP value Detected BuiltinType
true / false Boolean
int[-2^31, 2^31) Int32
int outside that range Int64
float Double
string String
DateTimeImmutable DateTime
array<int> Int32 array
array<float> Double array

This covers ~90% of cases. The remaining 10% need an explicit type override.

Explicit types

When the server rejects an auto-detected write with Bad_TypeMismatch:

php explicit Float
use PhpOpcua\Client\Types\BuiltinType;

$this->client->write('ns=2;s=Setpoint', 75.0, BuiltinType::Float);

// or through the multi-write builder:
$this->client->writeMulti(null)
    ->node('ns=2;s=Setpoint')->typed(75.0, BuiltinType::Float)
    ->execute();

The single-shot write() accepts the optional third BuiltinType argument. On the WriteMultiBuilder, call ->node($id) first then either ->value($v) (auto-detect) or ->typed($v, $type) (explicit) — the older "3-arg value(node, value, type)" pattern does not exist.

Common overrides:

Server expects PHP value you have Type to pass
Float float BuiltinType::Float
Int16 int BuiltinType::Int16
UInt32 int BuiltinType::UInt32
Byte small int BuiltinType::Byte
String for a numeric node numeric string BuiltinType::String

Batch write

php batch
$statuses = $this->client->writeMulti(null)
    ->node('ns=2;s=Setpoint')->value(75.0)
    ->node('ns=2;s=Mode')->value('Auto')
    ->node('ns=2;s=Run')->value(true)
    ->execute();

Returns an array of int per-write status codes (in the order of calls). Non-atomic — successful writes within the batch stand even if others fail. For atomic semantics, call a method on the server side — see Method calls.

Authorisation — Symfony Voter

php src/Security/PlcVoter.php
namespace App\Security;

use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

/**
 * @extends Voter<string, string>
 */
final class PlcVoter extends Voter
{
    public const WRITE = 'plc.write';

    protected function supports(string $attribute, mixed $subject): bool
    {
        return $attribute === self::WRITE && is_string($subject);
    }

    protected function voteOnAttribute(string $attribute, mixed $nodeId, TokenInterface $token): bool
    {
        $user = $token->getUser();
        if ($user === null) return false;

        if (str_contains($nodeId, 'Setpoint') && $token->getUser()->hasRole('ROLE_OPERATOR')) {
            return true;
        }
        if (str_contains($nodeId, 'Run') && $token->getUser()->hasRole('ROLE_SUPERVISOR')) {
            return true;
        }
        return false;
    }
}

In the controller:

php authorising in controller
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/api/plc/{node}', methods: ['PUT'], requirements: ['node' => '.+'])]
public function write(
    string $node,
    Request $request,
    OpcUaClientInterface $client,
): JsonResponse {
    $this->denyAccessUnlessGranted('plc.write', $node);

    $value = json_decode($request->getContent(), true)['value'] ?? null;
    if ($value === null) {
        return $this->json(['error' => 'missing value'], 400);
    }

    $client->write($node, $value);
    return $this->json(['status' => 'ok']);
}

Audit log — Doctrine entity

php src/Entity/PlcWriteAudit.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class PlcWriteAudit
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
    public ?int $id = null;

    #[ORM\Column(type: 'string', length: 100)]
    public string $userId;

    #[ORM\Column(type: 'string', length: 255)]
    public string $nodeId;

    #[ORM\Column(type: 'json')]
    public mixed $valueBefore = null;

    #[ORM\Column(type: 'json')]
    public mixed $valueRequested;

    #[ORM\Column(type: 'json')]
    public mixed $valueAfter = null;

    #[ORM\Column(type: 'datetime_immutable')]
    public \DateTimeImmutable $writtenAt;
}

Around the write:

php audit write
$before = $client->read($node)->getValue();
$client->write($node, $value);
$after  = $client->read($node)->getValue();

$audit = (new PlcWriteAudit())
    ->setUserId((string) $this->getUser()->getUserIdentifier())
    ->setNodeId($node)
    ->setValueBefore($before)
    ->setValueRequested($value)
    ->setValueAfter($after)
    ->setWrittenAt(new \DateTimeImmutable());

$this->em->persist($audit);
$this->em->flush();

Rate limiter

text config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        plc_write:
            policy: 'sliding_window'
            limit:  10
            interval: '1 minute'

In the controller:

php rate-limited write
use Symfony\Component\RateLimiter\RateLimiterFactory;

public function write(
    string $node,
    Request $request,
    RateLimiterFactory $plcWriteLimiter,
    OpcUaClientInterface $client,
): JsonResponse {
    $this->denyAccessUnlessGranted('plc.write', $node);

    $limit = $plcWriteLimiter->create($this->getUser()->getUserIdentifier())->consume();
    if (!$limit->isAccepted()) {
        return $this->json(['error' => 'rate limit'], 429);
    }

    $value = json_decode($request->getContent(), true)['value'] ?? null;
    $client->write($node, $value);

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

Two-step confirmation flow

For high-impact setpoint changes:

php confirmation token
// Step 1 — propose
$token = bin2hex(random_bytes(16));
$this->cache->save(
    $this->cache->getItem('plc-write.' . $token)
        ->set(['node' => $node, 'value' => $value, 'user' => $userId])
        ->expiresAfter(300)
);

return $this->json([
    'confirmation_token' => $token,
    'message' => "Setpoint will change from $before to $value. Confirm within 5 minutes.",
]);

// Step 2 — commit
$item = $this->cache->getItem('plc-write.' . $token);
if (!$item->isHit()) {
    return $this->json(['error' => 'token expired or invalid'], 419);
}
$pending = $item->get();
$this->cache->deleteItem('plc-write.' . $token);

if ($pending['user'] !== $userId) {
    return $this->json(['error' => 'token user mismatch'], 419);
}

$client->write($pending['node'], $pending['value']);
return $this->json(['status' => 'applied']);

Writing arrays

php array write
$client->write('ns=2;s=RecipeIngredients', [1.5, 2.0, 3.2, 0.8]);

// Explicit element type
use PhpOpcua\Client\Types\BuiltinType;
$client->writeMulti(null)
    ->node('ns=2;s=RecipeIngredients')->typed([1.5, 2.0, 3.2, 0.8], BuiltinType::Float)
    ->execute();

Multi-dimensional arrays

For multi-dimensional array writes, use the upstream PhpOpcua\Client\Types\Variant directly to specify arrayDimensions. The WriteMultiBuilder does not ship a ->dimensions([...]) setter; build a fully-typed Variant and hand it to ->typed():

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

$matrix = [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
$flat = array_merge(...$matrix);

$variant = new Variant($flat, BuiltinType::Double, arrayDimensions: [2, 3]);

$client->writeMulti(null)
    ->node('ns=2;s=Matrix')->typed($variant, BuiltinType::Double)
    ->execute();

Row-major flattening is the caller's responsibility for multi-dimensional arrays.

Error recovery

A failed write should not be silently retried — setpoint changes are not idempotent in the operator's mental model.

If retry is needed for transport errors only:

php bounded retry
use PhpOpcua\Client\Exception\ConnectionException;
use PhpOpcua\Client\Exception\ServiceException;

try {
    $client->write($node, $value);
} catch (ConnectionException $e) {
    sleep(1);
    $client->write($node, $value);   // single retry on transport error
} catch (ServiceException $e) {
    // Don't retry — server received and rejected. Surface to operator.
    throw $e;
}
Documentation