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

History

Reading historical values from a HistoryServer. Time-range queries, server-side aggregates, at-time reads, and the Doctrine bridge pattern.

Historized tags (most modern OPC UA servers do this for some subset) keep a time series. The historyRead service queries it.

Note

Not every server is a historian. Check the node's Historizing attribute or the server's AccessHistoryData capability bit before assuming history is available.

The real API — three flat methods

opcua-client exposes three flat historyRead* methods on OpcUaClientInterface (no fluent builder). The signatures are:

historyReadRaw(
    NodeId|string $nodeId,
    ?\DateTimeImmutable $startTime = null,
    ?\DateTimeImmutable $endTime = null,
    int $numValuesPerNode = 0,
    bool $returnBounds = false,
): array;   // DataValue[]

historyReadProcessed(
    NodeId|string $nodeId,
    \DateTimeImmutable $startTime,
    \DateTimeImmutable $endTime,
    float $processingInterval,
    NodeId $aggregateType,
): array;   // DataValue[]

historyReadAtTime(
    NodeId|string $nodeId,
    array $timestamps,   // DateTimeImmutable[]
): array;   // DataValue[]

All three return a chronological list of DataValue — same shape as a live read().

Raw history read

php raw history
use PhpOpcua\Client\OpcUaClientInterface;

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

    public function lastHour(string $node): array
    {
        return $this->client->historyReadRaw(
            $node,
            new \DateTimeImmutable('-1 hour'),
            new \DateTimeImmutable(),
        );
    }
}

numValuesPerNode = 0 (the default) means "no client-side cap"; the client transparently follows continuation points until the range is exhausted.

Continuation

Servers cap returned values per call. The client handles continuation points transparently — a 50 000-value range with a 1 000-value server cap becomes 50 internal calls.

For very large ranges, paginate manually to avoid building huge in-memory arrays:

php manual paging
$cursor = new \DateTimeImmutable('-1 day');
$end    = new \DateTimeImmutable();

while ($cursor < $end) {
    $chunkEnd = min($cursor->modify('+1 hour'), $end);

    $values = $this->client->historyReadRaw(
        'ns=2;s=Speed',
        $cursor,
        $chunkEnd,
    );

    foreach ($values as $dv) {
        $reading = (new PlcReading())
            ->setNodeId('ns=2;s=Speed')
            ->setValue($dv->getValue())
            ->setSourceAt($dv->sourceTimestamp);
        $this->em->persist($reading);
    }
    $this->em->flush();
    $this->em->clear();   // free entity memory

    $cursor = $chunkEnd;
}

Hour-sized chunks fit most usage.

Aggregates — historyReadProcessed

Most servers support server-side aggregates over time buckets. The wire savings are large — let the server collapse 30 000 raw values into 60 minute-buckets. Pass the aggregate-type NodeId that the server advertises (well-known aggregates live under ns=0 — e.g. Average is ns=0;i=2342).

php aggregate
use PhpOpcua\Client\Types\NodeId;

$buckets = $this->client->historyReadProcessed(
    'ns=2;s=Speed',
    new \DateTimeImmutable('-1 day'),
    new \DateTimeImmutable(),
    3600000.0,                              // 1 hour in milliseconds
    NodeId::numeric(0, 2342),               // Average aggregate type
);

Common aggregate NodeIds (server-dependent — verify against the server's Server.ServerCapabilities.AggregateFunctions):

Aggregate NodeId
Average ns=0;i=2342
Minimum ns=0;i=2346
Maximum ns=0;i=2347
Total ns=0;i=2344
Count ns=0;i=2352
TimeAverage ns=0;i=11285
StandardDeviationSample ns=0;i=11427

$processingInterval is the bucket width in milliseconds as a float3600000.0 is one hour, 60000.0 is one minute.

At-time reads — historyReadAtTime

For specific moments (the server interpolates / picks the closest historized point):

php at-time
$values = $this->client->historyReadAtTime(
    'ns=2;s=Speed',
    [
        new \DateTimeImmutable('2026-05-15 10:00:00'),
        new \DateTimeImmutable('2026-05-15 10:30:00'),
        new \DateTimeImmutable('2026-05-15 11:00:00'),
    ],
);

Returns one DataValue per requested timestamp in the order supplied.

Multi-node history

The three history methods operate on a single node ID. To pull history for several nodes, loop:

php multi-node
$nodes = ['ns=2;s=Speed', 'ns=2;s=Temperature', 'ns=2;s=Pressure'];
$results = [];
$from = new \DateTimeImmutable('-1 hour');
$to   = new \DateTimeImmutable();

foreach ($nodes as $node) {
    $results[$node] = $this->client->historyReadRaw($node, $from, $to);
}

For very wide fan-outs (50+ nodes), schedule each fetch on a Messenger queue instead of looping in the request.

Combining live + history

A common analytics pattern: last 24 hours of aggregated history plus the current live value:

php last 24h + current
use PhpOpcua\Client\Types\NodeId;

$series = $this->client->historyReadProcessed(
    'ns=2;s=Speed',
    new \DateTimeImmutable('-1 day'),
    new \DateTimeImmutable(),
    300000.0,                          // 5-minute buckets
    NodeId::numeric(0, 2342),          // Average
);

$series[] = $this->client->read('ns=2;s=Speed');

return $this->json(['series' => $series, 'latest' => end($series)]);

When the server isn't a historian

Build your own using a live subscription persisting to Doctrine — see Recipes · Persistent tag history.

Trade-offs:

Approach Best for
Server-side history Long retention, large data, low ops surface
Subscription → Doctrine Custom retention, Symfony-native queries
Both Short-term in Doctrine + long-term server-side

The third option is most common in mature plants.

Performance

Query Approx duration
1 node, 1 hour raw (~3600 vals) 200-500 ms
1 node, 1 day raw 1-3 s
1 node, 1 day @ 1-min average 100-300 ms
100 nodes, 1 hour raw 1-5 s

For analytics dashboards, always use historyReadProcessed.

In Messenger handlers

History reads are slow and bursty — dispatch via Messenger rather than running them in the request:

php src/Message/FetchDailyHistory.php
namespace App\Message;

final readonly class FetchDailyHistory
{
    public function __construct(
        public string $nodeId,
        public string $day,           // 'YYYY-MM-DD'
    ) {}
}
php handler
namespace App\MessageHandler;

use App\Entity\DailyPlcAggregate;
use App\Message\FetchDailyHistory;
use Doctrine\ORM\EntityManagerInterface;
use PhpOpcua\Client\OpcUaClientInterface;
use PhpOpcua\Client\Types\NodeId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class FetchDailyHistoryHandler
{
    public function __construct(
        private OpcUaClientInterface $client,
        private EntityManagerInterface $em,
    ) {}

    public function __invoke(FetchDailyHistory $message): void
    {
        $start = new \DateTimeImmutable($message->day . ' 00:00:00');
        $end   = $start->modify('+1 day');

        $buckets = $this->client->historyReadProcessed(
            $message->nodeId,
            $start,
            $end,
            3600000.0,                       // 1 hour buckets
            NodeId::numeric(0, 2342),        // Average
        );

        foreach ($buckets as $dv) {
            $agg = (new DailyPlcAggregate())
                ->setNodeId($message->nodeId)
                ->setHour($dv->sourceTimestamp)
                ->setAverage((float) $dv->getValue());
            $this->em->persist($agg);
        }
        $this->em->flush();
    }
}

Schedule daily via Symfony Scheduler:

php schedule
#[AsSchedule('plc-history')]
final class PlcHistorySchedule implements ScheduleProviderInterface
{
    public function getSchedule(): Schedule
    {
        $yesterday = (new \DateTimeImmutable('yesterday'))->format('Y-m-d');
        return (new Schedule())
            ->add(RecurringMessage::cron('0 1 * * *', new FetchDailyHistory('ns=2;s=Speed',       $yesterday)))
            ->add(RecurringMessage::cron('0 1 * * *', new FetchDailyHistory('ns=2;s=Temperature', $yesterday)));
    }
}

You've finished Operations. Continue with Session manager · Overview for the daemon deep-dive, or jump to Events · Overview for the listener side in managed mode.

Documentation