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
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:
$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).
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
float — 3600000.0 is one hour, 60000.0 is one minute.
At-time reads — historyReadAtTime
For specific moments (the server interpolates / picks the closest historized point):
$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:
$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:
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:
namespace App\Message;
final readonly class FetchDailyHistory
{
public function __construct(
public string $nodeId,
public string $day, // 'YYYY-MM-DD'
) {}
}
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:
#[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)));
}
}
Where to read next
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.