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

Twig

Twig templates for OPC UA data. Custom extensions, server-side rendering vs Mercure push, and the Twig Components pattern for reactive tiles.

For server-rendered OPC UA data, Twig is the standard. Pair with Mercure for real-time updates from a static template.

Basic — render a value

php controller
namespace App\Controller;

use PhpOpcua\Client\OpcUaClientInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class DashboardController extends AbstractController
{
    public function __construct(private OpcUaClientInterface $client) {}

    #[Route('/dashboard', methods: ['GET'])]
    public function index(): Response
    {
        return $this->render('dashboard/index.html.twig', [
            'speed'       => $this->client->read('ns=2;s=Speed')->getValue(),
            'temperature' => $this->client->read('ns=2;s=Temperature')->getValue(),
            'pressure'    => $this->client->read('ns=2;s=Pressure')->getValue(),
        ]);
    }
}
text templates/dashboard/index.html.twig
{% extends 'base.html.twig' %}

{% block body %}
<div class="grid grid-cols-3 gap-4">
    <div class="tile">
        <h3>Speed</h3>
        <div class="value">{{ speed|number_format(1) }}</div>
    </div>
    <div class="tile">
        <h3>Temperature</h3>
        <div class="value">{{ temperature|number_format(1) }}°C</div>
    </div>
    <div class="tile">
        <h3>Pressure</h3>
        <div class="value">{{ pressure|number_format(2) }} bar</div>
    </div>
</div>
{% endblock %}

Static SSR — reload to refresh.

With Mercure for live updates

Combine SSR for the initial render + Mercure for live updates:

text dashboard with Mercure
{% extends 'base.html.twig' %}

{% block body %}
<div class="grid grid-cols-3 gap-4">
    {% for tag in tags %}
    <div class="tile" data-node-id="{{ tag.nodeId }}">
        <h3>{{ tag.displayName }}</h3>
        <div class="value">{{ tag.value|number_format(1) }}</div>
    </div>
    {% endfor %}
</div>

<script>
const url = new URL("{{ mercure('/plc/all')|escape('js') }}");
const es = new EventSource(url);
es.onmessage = (e) => {
    const data = JSON.parse(e.data);
    const tile = document.querySelector(`[data-node-id="${data.node_id}"] .value`);
    if (tile && data.value !== undefined) {
        tile.textContent = data.value.toFixed(1);
    }
};
</script>
{% endblock %}

Initial values from the controller; live updates via Mercure.

A Twig extension for OPC UA

For one-off {{ opcua_read(...) }} in templates (use sparingly — don't put OPC UA calls in templates as a rule):

php src/Twig/OpcuaExtension.php
namespace App\Twig;

use PhpOpcua\Client\OpcUaClientInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

final class OpcuaExtension extends AbstractExtension
{
    public function __construct(private OpcUaClientInterface $client) {}

    public function getFunctions(): array
    {
        return [
            new TwigFunction('opcua_read', $this->read(...)),
            new TwigFunction('opcua_status', $this->statusName(...)),
        ];
    }

    public function read(string $nodeId): mixed
    {
        try {
            return $this->client->read($nodeId)->getValue();
        } catch (\Throwable) {
            return null;
        }
    }

    public function statusName(int $statusCode): string
    {
        return \PhpOpcua\Client\Types\StatusCode::getName($statusCode);
    }
}

Usage:

text usage
Current speed: {{ opcua_read('ns=2;s=Speed') }}

Note

Don't read OPC UA from templates in production. Each read is a network round-trip. Put values in the controller and pass to the template, or cache aggressively.

A Symfony UX Twig Component

For a reactive component that auto-updates via Mercure:

bash install
composer require symfony/ux-twig-component
composer require symfony/ux-live-component
php src/Twig/Components/TagTile.php
namespace App\Twig\Components;

use PhpOpcua\Client\OpcUaClientInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\UX\TwigComponent\Attribute\PreMount;

#[AsTwigComponent]
final class TagTile
{
    public string $nodeId;
    public string $label = '';
    public string $unit = '';
    public mixed $value = null;
    public bool $good = false;

    public function __construct(private OpcUaClientInterface $client) {}

    #[PreMount]
    public function fetch(array $data): array
    {
        $dv = $this->client->read($data['nodeId']);
        $data['value'] = $dv->getValue();
        $data['good']  = $dv->statusCode === 0;
        return $data;
    }
}
text templates/components/TagTile.html.twig
<div class="tile {{ good ? '' : 'tile-bad' }}" data-node-id="{{ nodeId }}">
    <h3>{{ label }}</h3>
    <div class="value">
        {{ value is not null ? value|number_format(2) ~ ' ' ~ unit : '—' }}
    </div>
</div>

In any template:

text usage
<twig:TagTile nodeId="ns=2;s=Speed"       label="Speed"       unit="m/min" />
<twig:TagTile nodeId="ns=2;s=Temperature" label="Temperature" unit="°C" />
<twig:TagTile nodeId="ns=2;s=Pressure"    label="Pressure"    unit="bar" />

Combine with Live Components for full reactivity without hand-written JS — see the Symfony UX docs.

Formatting OPC UA DataValue

text formatters
{% set dv = some_data_value %}

Value:           {{ dv.value }}
Status:          {{ dv.statusCode == 0 ? 'Good' : 'Bad (' ~ dv.statusCode ~ ')' }}
Source time:     {{ dv.sourceTimestamp|date('Y-m-d H:i:s.v') }}
Server time:     {{ dv.serverTimestamp|date('Y-m-d H:i:s.v') }}

dv.value works because DataValue::getValue() is exposed as a property in Twig.

A render filter for status codes

php status filter
public function getFilters(): array
{
    return [
        new TwigFilter('opcua_status', $this->statusName(...)),
    ];
}

Usage:

text usage
{{ 0x80020000|opcua_status }}    {# Bad_InternalError #}