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

Writing

Writing values to PLC tags. Type detection, explicit types, batch writes, status verification — and the responsibilities Laravel apps inherit when they mutate physical equipment.

Opcua::write() sends a value to a writable OPC UA node. Unlike reading, writes have real-world consequences — they change setpoints, command actuators, toggle alarm acknowledgements.

Note

Writes mutate physical equipment. Treat them with the same care as DELETE FROM in SQL. Authorisation, audit, and rate-limiting belong at the application layer.

Basic write

php write
use PhpOpcua\LaravelOpcua\Facades\Opcua;

Opcua::write('ns=2;s=Setpoint', 75.0);

Returns the OPC UA status code as an int. Status 0 is "Good". Anything else means the server rejected the write — typically BadTypeMismatch, BadNotWritable, BadUserAccessDenied. Check with the StatusCode helper:

use PhpOpcua\Client\Types\StatusCode;

$status = Opcua::write('ns=2;s=Setpoint', 75.0);
if (! StatusCode::isGood($status)) {
    throw new RuntimeException(
        'Setpoint write rejected: ' . StatusCode::getName($status)
    );
}

A ConnectionException is thrown for transport failures (TCP, etc.).

Type detection

Opcua::write() infers the OPC UA BuiltinType from the PHP value:

PHP value Detected BuiltinType
true / false Boolean
int in [-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 is adequate for most cases but it has a tell — if the node's actual type is Float, writing a PHP float sends Double, and the server returns Bad_TypeMismatch.

Explicit types

When the server rejects an auto-detected write, set the type explicitly — pass it as the third argument to write():

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

Opcua::write('ns=2;s=Setpoint', 75.0, BuiltinType::Float);

Common reasons to override:

Server expects PHP value you have Explicit type
Float float BuiltinType::Float
Int16 int BuiltinType::Int16
UInt32 int BuiltinType::UInt32
Byte small int BuiltinType::Byte
String for a numeric tag numeric string BuiltinType::String

Batch write

Call writeMulti() with no arguments to get a WriteMultiBuilder; chain node() followed by value() (or typed() for an explicit type) for each entry, then execute():

php batch write
use PhpOpcua\Client\Types\BuiltinType;

$statuses = Opcua::writeMulti()
    ->node('ns=2;s=Setpoint')->value(75.0)
    ->node('ns=2;s=Mode')->value('Auto')
    ->node('ns=2;s=Run')->typed(true, BuiltinType::Boolean)
    ->execute();

value() takes a single argument (auto-detect type). typed() takes (value, BuiltinType). The node() call is mandatory before each value() / typed() — it sets the target NodeId for the next write item.

execute() returns int[] — one OPC UA status code per item, in the order added. The call is non-atomic: a partial failure still landed the successful writes.

For atomic semantics (call multi-write methods on the server) see Method calls.

Status verification

A write that returns successfully at the OPC UA service level might still not have landed at the device:

php write-then-read
Opcua::write('ns=2;s=Setpoint', 75.0);

usleep(200_000);  // give the PLC a beat

$dv = Opcua::read('ns=2;s=Setpoint');
if (abs((float) $dv->getValue() - 75.0) > 0.01) {
    throw new RuntimeException("Setpoint did not stick");
}

This is server-specific — most production PLCs honour writes immediately, but a small number of complex devices defer the write to an internal cycle. Match the verification approach to the device.

Authorisation

The package itself doesn't gate writes. Three places to put authorisation:

1 — Laravel policy

php policy
class PlcTagPolicy
{
    public function write(User $user, string $nodeId): bool
    {
        // role-based — operators can write setpoints, only supervisors can toggle Run
        if (str_ends_with($nodeId, 'Setpoint')) {
            return $user->hasRole('operator');
        }
        if (str_ends_with($nodeId, 'Run')) {
            return $user->hasRole('supervisor');
        }
        return false;
    }
}

// In the controller
$this->authorize('write', $request->input('node_id'));
Opcua::write($request->input('node_id'), $request->input('value'));

2 — Audit log

php audit log
$user = $request->user();
$nodeId = $request->input('node_id');
$value = $request->input('value');

$before = Opcua::read($nodeId)->getValue();
Opcua::write($nodeId, $value);
$after = Opcua::read($nodeId)->getValue();

PlcWriteAudit::create([
    'user_id'    => $user->id,
    'node_id'    => $nodeId,
    'before'     => $before,
    'requested'  => $value,
    'after'      => $after,
    'written_at' => now(),
]);

3 — Rate limit

php rate limit
Route::middleware(['auth', 'throttle:10,1'])
    ->post('/plc/write', WriteController::class);

For per-tag rate limits (no more than one setpoint change per minute, regardless of user), use Laravel's RateLimiter:

php per-tag rate limit
$key = "opcua-write:{$nodeId}";
if (! RateLimiter::attempt($key, maxAttempts: 1, callback: fn() => null, decaySeconds: 60)) {
    abort(429, "Tag updated too recently — wait before changing again");
}

Opcua::write($nodeId, $value);

Confirmation flows

For setpoints with large impact (a recipe load, a line speed change of 20%+), introduce a two-step confirmation:

php confirmation token
// step 1 — propose
$token = (string) Str::uuid();
Cache::put("plc-write:{$token}", [
    'node'  => $nodeId,
    'value' => $value,
    'user'  => $user->id,
], minutes: 5);

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

// step 2 — commit
$pending = Cache::pull("plc-write:{$token}");
abort_unless($pending && $pending['user'] === $user->id, 419, 'Invalid token');

Opcua::write($pending['node'], $pending['value']);

The package gives you the wire; the safety layer is yours.

Writing arrays

php array write
Opcua::write('ns=2;s=RecipeIngredients', [1.5, 2.0, 3.2, 0.8]);

The package detects array uniformly — all-int → Int32[], all- float → Double[], mixed → falls back to Variant[].

For arrays of explicit type, pass it as the third argument to write():

php typed array write
Opcua::write(
    'ns=2;s=RecipeIngredients',
    [1.5, 2.0, 3.2, 0.8],
    BuiltinType::Float,
);

Multi-dimensional arrays

Multi-dimensional arrays are written by flattening yourself and relying on the server's declared ArrayDimensions attribute on the target node. The fluent builder does not expose a dimensions() modifier — encode the layout into the node configuration on the server side, then write the flattened array:

php 2D array write
$matrix = [
    [1.0, 2.0, 3.0],
    [4.0, 5.0, 6.0],
];

// Server's ns=2;s=Matrix is declared with ArrayDimensions = [2, 3].
// The package encodes the array as a flat Double[6] in row-major order.
Opcua::write('ns=2;s=Matrix', array_merge(...$matrix), BuiltinType::Double);

Writing structures

OPC UA structures use the opcua-client structure handling — a builder-internal feature, mostly identical between direct and Laravel paths.

Error recovery

A failed write should not be silently retried. Setpoint changes are not idempotent in the user's mental model — "I sent 75, the operator received feedback that it failed, then 30 seconds later the PLC accepted 75 anyway" is worse than a clean failure.

If retry is needed, scope it tightly:

php bounded retry
try {
    Opcua::write($node, $value);
} catch (ConnectionException $e) {
    // single retry on transport error only
    sleep(1);
    Opcua::write($node, $value);
}

ServiceException indicates the server received and rejected the write. Retry will not help — surface to the operator.