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
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 returnedBad_*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:
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
$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
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:
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
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:
$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
framework:
rate_limiter:
plc_write:
policy: 'sliding_window'
limit: 10
interval: '1 minute'
In the controller:
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:
// 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
$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:
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;
}
Where to read next
- Browsing — discovering writable nodes.
- Method calls — atomic multi-write alternatives.
- Recipes · Alarm routing — acknowledgement writes via the Notifier component.