laravel-opcua · master
Docs · Recipes

Using companion specs

Loading OPC UA companion specifications via opcua-client-nodeset to get type-aware browsing and named accessors. The Laravel-side wiring for MachineTool, PackML, and DI specs.

OPC UA companion specifications define typed node hierarchies for specific industries: MachineTool, PackML, Robotics, DI (Device Information). The opcua-client-nodeset package gives type-aware access to these — and the Laravel package picks them up automatically.

Install

bash terminal
composer require php-opcua/opcua-client-nodeset

The package's discovery mechanism auto-registers nodesets from vendor/php-opcua/opcua-client-nodeset/nodesets/. No additional config.

What you get

Without companion specs:

php raw browse
$nodes = Opcua::browseRecursive('ns=4;s=MachineTool', maxDepth: 5);
// returns: array of ReferenceDescription — generic

With companion specs:

php typed browse
use PhpOpcua\Client\Nodeset\MachineTool\MachineToolType;

$machine = Opcua::nodeset(MachineToolType::class, 'ns=4;s=MachineTool');

// Strongly-typed access
$alarms      = $machine->getAlarms();          // array of MachineToolAlarm
$production  = $machine->getProduction();      // ProductionType
$equipment   = $machine->getEquipment();       // ToolListType

// No string-fiddling, no walking the address space

The PHP classes correspond to the spec's defined ObjectType hierarchy.

Available companion specs

The opcua-client-nodeset package bundles:

Companion spec PHP namespace Use case
DI PhpOpcua\Client\Nodeset\Di\ Device information, generic
MachineTool PhpOpcua\Client\Nodeset\MachineTool\ CNCs, lathes, mills
Robotics PhpOpcua\Client\Nodeset\Robotics\ Industrial robots
PackML PhpOpcua\Client\Nodeset\PackML\ Packaging machinery
Machinery PhpOpcua\Client\Nodeset\Machinery\ Generic industrial machinery

Plus several more — see the opcua-client-nodeset readme.

End-to-end — production monitor for a MachineTool

The OPC UA MachineTool spec defines a Production object with ActiveProgram, ActiveTool, OperationMode properties. A Laravel-side monitor:

php model
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class MachineToolReading extends Model
{
    protected $guarded = [];
    public $timestamps = false;
    protected $casts = ['read_at' => 'datetime'];
}
php migration
Schema::create('machine_tool_readings', function (Blueprint $table) {
    $table->id();
    $table->string('machine_id');
    $table->string('active_program')->nullable();
    $table->string('active_tool')->nullable();
    $table->string('operation_mode')->nullable();
    $table->integer('part_count')->nullable();
    $table->timestamp('read_at');
});
php poll command
namespace App\Console\Commands;

use App\Models\MachineToolReading;
use Illuminate\Console\Command;
use PhpOpcua\Client\Nodeset\MachineTool\MachineToolType;
use PhpOpcua\LaravelOpcua\Facades\Opcua;

class PollMachineTool extends Command
{
    protected $signature = 'machine:poll {machine-id : The MachineTool root node}';

    public function handle(): int
    {
        $machineNodeId = $this->argument('machine-id');

        $machine = Opcua::nodeset(MachineToolType::class, $machineNodeId);

        $production = $machine->getProduction();
        $reading = MachineToolReading::create([
            'machine_id'     => $machineNodeId,
            'active_program' => $production->getActiveProgram()?->getName()->value,
            'active_tool'    => $production->getActiveTool()?->getName()->value,
            'operation_mode' => $production->getOperationMode()->value,
            'part_count'     => (int) $production->getPartCount()->value,
            'read_at'        => now(),
        ]);

        $this->table(['Field', 'Value'], collect($reading->toArray())->map(fn ($v, $k) => [$k, (string) $v])->all());

        return self::SUCCESS;
    }
}

Run every minute via the scheduler:

php schedule
$schedule->command('machine:poll', ['ns=4;s=MachineA'])->everyMinute();

Type discovery

To see what methods are available on a typed node:

bash tinker
php artisan tinker
> get_class_methods(\PhpOpcua\Client\Nodeset\MachineTool\MachineToolType::class);

…or just look at the class — opcua-client-nodeset generates classes with docblocks listing every typed property.

When the typed accessor returns null

A null from getActiveProgram() means the device doesn't populate that node. Two reasons:

  1. The device doesn't support that part of the spec. Common.
  2. The node is currently null (active program might be null between jobs).

Always null-check. The PHP types help — typed accessors return ?T for nullable nodes.

Working with alarm types

The MachineTool spec defines alarm types:

php typed alarms
use PhpOpcua\Client\Nodeset\MachineTool\MachineToolAlarm;

$alarms = $machine->getAlarms();

foreach ($alarms as $alarm) {
    if ($alarm instanceof \PhpOpcua\Client\Nodeset\MachineTool\AxisAlarm) {
        // Strongly-typed access to axis-specific fields
        echo "Axis {$alarm->getAxisId()->value}: {$alarm->getMessage()->value}\n";
    }
}

Type narrowing with instanceof lets you handle subtypes specifically.

Subscribing to typed events

The subscription side uses createSubscription() + createEventMonitoredItem() directly (see Operations · Subscriptions); the listener can use type-aware decoding on the EventNotificationReceived::$eventFields array:

php typed event listener
use PhpOpcua\Client\Event\EventNotificationReceived;

class HandleMachineToolAlarm implements ShouldQueue
{
    public function handle(EventNotificationReceived $event): void
    {
        $f = $event->eventFields;
        if (empty($f['EventType'])) return;

        $alarm = \PhpOpcua\Client\Nodeset\MachineTool\AlarmDecoder::decode($f);

        if ($alarm instanceof \PhpOpcua\Client\Nodeset\MachineTool\AxisAlarm) {
            \App\Models\AxisAlarm::create([
                'client_handle' => $event->clientHandle,
                'axis_id'       => $alarm->axisId,
                'message'       => $alarm->message,
                'severity'      => $f['Severity'] ?? null,
            ]);
        }
    }
}

AlarmDecoder::decode() is a opcua-client-nodeset helper that maps the raw event-fields array to a typed class.

Custom companion specs

For internal / proprietary companion specs (most plants have some), define your own types:

php custom type
namespace App\Opcua\Nodeset\Acme;

use PhpOpcua\Client\Nodeset\BaseNodesetType;

class AcmeReactorType extends BaseNodesetType
{
    public function getTemperature(): ?\PhpOpcua\Client\Types\DataValue
    {
        return $this->readChild('Temperature');
    }

    public function getPressure(): ?\PhpOpcua\Client\Types\DataValue
    {
        return $this->readChild('Pressure');
    }

    public function getState(): string
    {
        return (string) $this->readChild('State')->value;
    }
}

Use it identically:

php usage
$reactor = Opcua::nodeset(\App\Opcua\Nodeset\Acme\AcmeReactorType::class, 'ns=2;s=Reactor1');

echo $reactor->getTemperature()->value;

The trade-off

Approach Pros Cons
Raw browse / read Universal — works against any OPC UA server String-fiddling, no type safety
Companion specs Type-safe, IDE auto-complete, idiomatic Only works against spec-conformant servers

If your servers conform to the spec (Siemens, Beckhoff, Rockwell all do for their respective specs), companion specs are dramatically nicer. If your servers are bespoke, raw is fine.

Performance

A typed accessor reads the underlying node lazily. $machine->getProduction() makes one round-trip; $production->getActiveProgram() makes another. For multi-property reads, the typed API hides batching — internally, the package uses executeMany() where possible.

To force batch behaviour for a known set of properties:

php batch typed read
$snapshot = $machine->snapshot([
    'production.active_program',
    'production.active_tool',
    'production.operation_mode',
    'production.part_count',
]);
// $snapshot is an array of resolved values — one round-trip

See the opcua-client-nodeset README for the full snapshot API.

Documentation