davos / graphy
A PHP abstraction layer for creating, updating, and fetching RRDTool databases with fluent model-based APIs.
Requires
- php: ^8.3
- ext-ctype: *
- ext-mbstring: *
- ext-rrd: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^12.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
RRDTool knows fixed steps. It does not know what a calendar day, week, month, or year is.
Graphy is a typed, fluent PHP API for creating, updating, and fetching RRDTool databases. Its main addition is calendar-aligned, timezone- and DST-aware grouping on top of existing RRD archives.
Screen.Recording.2026-07-13.at.13.44.36.mov
Why Graphy?
Graphy gives you:
- model-based RRD definitions;
- fluent create, update, and fetch operations;
- automatic archive selection by consolidation function and resolution;
- bounded, chunked reads for large time ranges;
- calendar-aware grouping by second, minute, hour, day, week, month, or year;
- timezone- and DST-aware labels;
- typed exceptions instead of
falseor raw extension errors.
Graphy does not replace RRDTool. It provides a safer, higher-level PHP API around the ext-rrd extension.
Table of contents
- Installation
- Quick start
- Configuration
- Defining a model
- Creating an RRD
- Updating data
- Fetching data
- Grouping into calendar buckets
- Data source reference
- Archive reference
- Duration and time syntax
- Native RRDTool flags
- File organization with
path_mapper - Error handling
- Troubleshooting
- Examples
- Contributing
- License
Installation
composer require davos/graphy
Requirements
- PHP 8.3 or newer;
- the RRD PHP extension;
ext-mbstring;ext-ctype.
Verify that the RRD extension is loaded:
php -m | grep -i rrd
Quick start
Configure Graphy once during application bootstrap:
use Davos\Graphy\Manager\Factory\ManagerFactory; ManagerFactory::configure([ 'path' => __DIR__ . '/storage/rrd', 'path_mapper' => false, 'driver' => 'ext', 'permission' => 0644, 'create_directories' => true, 'directory_permission' => 0775, 'timezone' => 'UTC', ]);
Define an RRD model:
<?php namespace App\Models; use Davos\Graphy\Builder\DS; use Davos\Graphy\Builder\RRA; use Davos\Graphy\RRD; use Davos\Graphy\ValueObjects\Duration; final class Power extends RRD { protected string|Duration $step = '1s'; protected string $start = 'now-2y'; protected function dataSources(): array { return [ DS::name('watts') ->gauge() ->heartbeat(300) ->min(0) ->max(24000), ]; } protected function roundRobinArchives(): array { return [ 'raw' => RRA::average()->steps(1)->rows(864000), '1m_avg' => RRA::average()->steps(60)->rows(129600), '1h_avg' => RRA::average()->steps(3600)->rows(13392), '1d_avg' => RRA::average()->steps(86400)->rows(3660), ]; } }
Create the file and write a value:
Power::create('power'); Power::update('power', [ 'watts' => 42, ]);
Fetch the last day at one-minute resolution:
$data = Power::fetch('power') ->average() ->resolution('1m') ->start('now-1d') ->end('now') ->run() ->get();
Graphy normalizes bare filenames to .rrd, so power and power.rrd address the same file.
Configuration
All configuration keys shown below are required by the current configuration object.
use Davos\Graphy\Manager\Factory\ManagerFactory; ManagerFactory::configure([ 'path' => __DIR__ . '/storage/rrd', 'path_mapper' => false, 'driver' => 'ext', 'permission' => 0644, 'create_directories' => true, 'directory_permission' => 0775, 'timezone' => 'UTC', ]);
| Key | Type | Description |
|---|---|---|
path |
string |
Base directory for relative RRD filenames. |
path_mapper |
callable|false |
Optionally maps a filename to a relative subpath. |
driver |
string |
RRD backend. Currently only ext is supported. |
permission |
int |
Mode applied to newly created .rrd files. |
create_directories |
bool |
Creates missing parent directories when enabled. |
directory_permission |
int |
Mode applied to newly created directories. |
timezone |
string |
Default IANA timezone used for grouping and labels. |
Calling ManagerFactory::configure() again replaces the current configuration for subsequent operations.
Defining a model
An RRD model describes:
- the primary data point step;
- the initial start time;
- one or more data sources;
- one or more round-robin archives.
The following RRDTool definition:
rrdtool create power.rrd \ --start now-2y \ --step 1 \ DS:watts:GAUGE:300:0:24000 \ RRA:AVERAGE:0.5:1:864000 \ RRA:AVERAGE:0.5:60:129600 \ RRA:AVERAGE:0.5:3600:13392 \ RRA:AVERAGE:0.5:86400:3660
can be represented as:
<?php namespace App\Models; use Davos\Graphy\Builder\DS; use Davos\Graphy\Builder\RRA; use Davos\Graphy\RRD; use Davos\Graphy\ValueObjects\Duration; final class Power extends RRD { protected string|Duration $step = '1s'; protected string $start = 'now-2y'; protected function dataSources(): array { return [ DS::name('watts') ->gauge() ->heartbeat(300) ->min(0) ->max(24000), ]; } protected function roundRobinArchives(): array { return [ 'raw' => RRA::average()->steps(1)->rows(864000), '1m_avg' => RRA::average()->steps(60)->rows(129600), '1h_avg' => RRA::average()->steps(3600)->rows(13392), '1d_avg' => RRA::average()->steps(86400)->rows(3660), ]; } }
dataSources() and roundRobinArchives() are abstract methods and must be implemented by every model.
Archive keys are optional, but naming them makes fromArchive() calls clearer and less fragile than numeric indexes.
Understanding archive resolution and retention
For every RRA:
archive resolution = model step × RRA steps
archive retention = archive resolution × RRA rows
With a model step of one second:
'1m_avg' => RRA::average() ->steps(60) ->rows(129600),
the archive has:
resolution = 1 second × 60 = 1 minute
retention = 1 minute × 129600 = 90 days
Creating an RRD
Power::create('power');
Graphy resolves the model definition and calls the RRD extension with the equivalent create options.
The operation throws an exception when the command definition is invalid or RRDTool rejects the operation.
Updating data
One sample at the current time
Power::update('power', [ 'watts' => 42, ]);
Samples keyed by Unix timestamp
Power::update('power', [ 1699920000 => ['watts' => 42], 1699920300 => ['watts' => 45], ]);
Explicit batch format
Power::update('power', [ [ 'time' => 1699920000, 'values' => ['watts' => 42], ], [ 'time' => 1699920300, 'values' => ['watts' => 45], ], ]);
When a defined data source is omitted from a sample, Graphy writes U (unknown) for that data source.
Use integer Unix timestamps for explicit sample times.
Fetching data
Select an archive by consolidation function and resolution
$data = Power::fetch('power') ->average() ->resolution('1m') ->start('now-1d') ->end('now') ->run() ->get();
Available consolidation functions:
->average() ->min() ->max() ->last()
The requested consolidation function and resolution must match an archive defined by the model.
Graphy:
- finds the matching archive;
- clamps the request to the archive's available retention range and to the current time;
- aligns the request to the archive resolution;
- splits large ranges into bounded chunks;
- merges the chunks into one logical result.
The default chunk size is 10,000 data points. It can be changed per fetch:
$fetcher = Power::fetch('power', chunkSize: 5_000) ->average() ->resolution('1m') ->start('now-1y') ->run();
Select a named archive
A named archive supplies its own consolidation function, resolution, and full retention window:
$data = Power::fetch('power') ->fromArchive('1h_avg') ->run() ->get();
fromArchive() uses the archive key or numeric index from roundRobinArchives().
Result shape
get() returns:
/** * @var array{ * timestamps: list<int|string|null|false>, * datasets: array<string, list<float|null>> * } $data */
Example:
[
'timestamps' => [
1721347200,
1721347260,
],
'datasets' => [
'watts' => [
124.5,
null,
],
],
]
RRD NaN values are converted to null by get().
Stream results with cursor()
Use cursor() when processing a large result row by row:
$cursor = Power::fetch('power') ->average() ->resolution('1m') ->start('now-1y') ->run() ->cursor(); foreach ($cursor as $timestamp => $datasets) { $watts = $datasets['watts']; // Process or stream the row without building the complete get() array. }
cursor() is lazy. It yields timestamps as keys and data-source maps as values.
Unlike get(), cursor() exposes raw numeric values from the fetch pipeline; normalize NaN yourself when needed.
Grouping into calendar buckets
An RRD archive stores fixed-resolution points. A one-minute archive knows about 60-second steps, not calendar days or months.
Graphy's group() aggregates those points into calendar-aligned buckets after fetching.
use Davos\Graphy\Fetch\Group\Interval\DayInterval; $data = Power::fetch('power') ->average() ->resolution('1m') ->start('now-30d') ->end('now') ->timezone('Europe/Sofia') ->run() ->group(DayInterval::for(1)) ->labels(DayInterval::for(1)) ->get();
The timezone controls where calendar boundaries occur. This matters for local midnight and daylight-saving transitions.
Available intervals
use Davos\Graphy\Fetch\Group\Interval\SecondInterval; use Davos\Graphy\Fetch\Group\Interval\MinuteInterval; use Davos\Graphy\Fetch\Group\Interval\HourInterval; use Davos\Graphy\Fetch\Group\Interval\DayInterval; use Davos\Graphy\Fetch\Group\Interval\WeekInterval; use Davos\Graphy\Fetch\Group\Interval\MonthInterval; use Davos\Graphy\Fetch\Group\Interval\YearInterval;
Every interval accepts a positive multiplier:
DayInterval::for(1); // daily DayInterval::for(7); // every seven days WeekInterval::for(2); // every two weeks
Weeks start on Monday.
group() and labels() are independent
group() aggregates values:
$fetcher->group(DayInterval::for(1));
labels() formats timestamp keys:
$fetcher->labels( DayInterval::for(1), 'Y-m-d', );
Labels can be used without grouping:
$data = Power::fetch('power') ->average() ->resolution('1h') ->start('now-7d') ->timezone('Europe/Sofia') ->run() ->labels(DayInterval::for(1), 'Y-m-d') ->get();
For points that are not on a label boundary, the default behaviour is to preserve the Unix timestamp.
The third labels() argument changes that behaviour:
// Preserve the Unix timestamp. ->labels(DayInterval::for(1), 'Y-m-d', 'ts') // Return null for non-boundary points. ->labels(DayInterval::for(1), 'Y-m-d', null) // Return false for non-boundary points. ->labels(DayInterval::for(1), 'Y-m-d', false) // Format non-boundary points with another date format. ->labels(DayInterval::for(1), 'Y-m-d', 'H:i')
Grouping constraints
The archive resolution must divide evenly into the requested grouping interval.
Valid examples:
1-minute archive → day
30-minute archive → day
1-hour archive → week
Invalid example:
7-hour archive → day
Invalid combinations throw CommandDefinitionException instead of producing silently misaligned results.
Complete buckets
A grouped bucket is emitted when its calendar boundary is reached. A trailing bucket that has not reached its closing boundary is not included in the grouped result.
For example, a daily query ending at 15:00 may return completed days but omit the still-incomplete current day.
Data source reference
Create a data source with:
DS::name('watts')
Then choose exactly one data-source type.
| Method | RRD type | Typical use |
|---|---|---|
gauge() |
GAUGE |
Direct measurements such as temperature, load, or power. |
counter() |
COUNTER |
Continuously increasing counters with overflow handling. |
dcounter() |
DCOUNTER |
Double-precision counter values. |
derive() |
DERIVE |
Rates derived from increasing or decreasing values. |
dderive() |
DDERIVE |
Double-precision derive values. |
absolute() |
ABSOLUTE |
Counters reset after every read. |
compute($expression) |
COMPUTE |
Values calculated from other data sources using RPN. |
For non-COMPUTE data sources, configure:
DS::name('watts') ->gauge() ->heartbeat(300) ->min(0) ->max(24000);
heartbeat()is the maximum accepted gap between updates before the value becomes unknown;min()andmax()define accepted bounds;- use RRD-compatible values such as
'U'when a bound is unknown.
A computed data source uses an RPN expression:
DS::name('kilowatts') ->compute('watts,1000,/');
Archive reference
Create archives with one of:
RRA::average() RRA::min() RRA::max() RRA::last()
Then configure:
RRA::average() ->xff(0.5) ->steps(60) ->rows(129600);
| Method | Meaning |
|---|---|
xff(float $value) |
XFiles factor: tolerated proportion of unknown primary data points. |
steps(int $steps) |
Primary data points consolidated into one archive row. |
rows(int $rows) |
Number of rows retained by the archive. |
The default XFiles factor is 0.5.
Duration and time syntax
Duration values
Graphy accepts integers or strings containing a non-negative integer and an optional unit:
| Unit | Meaning |
|---|---|
no suffix / s |
seconds |
m |
minutes |
h |
hours |
d |
fixed 24-hour days |
w |
fixed seven-day weeks |
M |
fixed 31-day months |
y |
fixed 366-day years |
Examples:
'30' '30s' '5m' '2h' '7d' '2w' '3M' '1y'
M and y are fixed-duration aliases used for RRD calculations. They are not calendar-aware units. Calendar months and years are handled by MonthInterval and YearInterval during grouping.
Fetch time references
start() and end() accept:
->start(1721347200) // Unix timestamp as an integer ->start('now-1d') ->start('end-30m') ->start('start+2h') ->start('2026-07-20 12:00:00') ->end('now')
Supported RRD-style anchors are:
start
end
now
and can be combined with + or - and a duration.
Pass Unix timestamps as integers. A digits-only string passed through the fluent fetch API is interpreted as a relative duration, not as an absolute Unix timestamp.
When end() is omitted, it defaults to now.
Native RRDTool flags
Graphy exposes supported native options through Flag objects and operation-specific constants.
Create flags
use Davos\Graphy\Create\CreateOptions; use Davos\Graphy\ValueObjects\Flag; Power::create('power', [ new Flag(CreateOptions::NO_OVERWRITE), ]);
Supported create constants:
CreateOptions::STEP CreateOptions::START CreateOptions::NO_OVERWRITE CreateOptions::DAEMON CreateOptions::TEMPLATE CreateOptions::FROM_SOURCE
Model-defined step and start are supplied automatically. User flags can override generated defaults where the operation permits it.
Update flags
use Davos\Graphy\Update\UpdateOptions; use Davos\Graphy\ValueObjects\Flag; Power::update( 'power', ['watts' => 42], [ new Flag(UpdateOptions::SKIP_PAST_UPDATES), ], );
Supported update constants:
UpdateOptions::TEMPLATE UpdateOptions::SKIP_PAST_UPDATES UpdateOptions::DAEMON
Fetch flags
Fetch range and resolution options are normally generated by the fluent API and chunking pipeline.
Supported fetch constants are:
FetchOptions::RESOLUTION FetchOptions::START FetchOptions::END FetchOptions::ALIGN_START FetchOptions::DAEMON
File organization with path_mapper
For a small number of files, keep:
'path_mapper' => false,
Every relative filename will be stored directly under the configured path.
For many dynamically named files, use path_mapper to shard them into deterministic subdirectories:
ManagerFactory::configure([ 'path' => __DIR__ . '/storage/rrd', 'path_mapper' => function (string $file): string { [$category, $id] = explode('_', $file, 2); $bucket = ((int) $id) % 5; return match ($category) { 'zone' => "zones/{$bucket}/{$file}", 'traffic' => "traffic/{$bucket}/{$file}", 'client' => "clients/{$bucket}/{$file}", default => $file, }; }, 'driver' => 'ext', 'permission' => 0644, 'create_directories' => true, 'directory_permission' => 0775, 'timezone' => 'UTC', ]);
Calling:
Zone::create('zone_123');
resolves to:
storage/rrd/zones/3/zone_123.rrd
The mapper receives a filename already normalized to end in .rrd.
Important behaviour:
- absolute paths bypass both
pathandpath_mapper; - relative mapper results are resolved under
path; - when
create_directoriesistrue, missing parent directories are created recursively; - when
create_directoriesisfalse, a missing directory causesCommandDefinitionException; - new directories use
directory_permission; - newly created RRD files use
permission.
A mapper should be deterministic: the same logical filename must always return the same path.
Error handling
Graphy throws typed exceptions instead of returning false or raw error strings.
use Davos\Graphy\Shared\Exceptions\CommandDefinitionException; use Davos\Graphy\Shared\Exceptions\ConfigException; use Davos\Graphy\Shared\Exceptions\RrdToolExecutionException; try { $data = Power::fetch('power') ->average() ->resolution('1m') ->start('now-1d') ->run() ->get(); } catch (CommandDefinitionException $exception) { // Invalid Graphy command definition: // missing archive, invalid range, invalid grouping resolution, etc. } catch (RrdToolExecutionException $exception) { // The RRD extension or RRDTool rejected the generated command. } catch (ConfigException $exception) { // Missing or invalid Graphy configuration. }
| Exception | Meaning |
|---|---|
CommandDefinitionException |
The requested operation cannot be represented safely or validly. |
RrdToolExecutionException |
RRDTool rejected the generated command. |
ConfigException |
Configuration is missing or invalid. |
Value-object-specific exceptions may also be thrown for invalid durations or time references.
Troubleshooting
ext-rrd is missing
Check:
php -m | grep -i rrd
The extension must be available to the same PHP binary that runs Composer, tests, workers, or the web application.
No matching archive was found
The requested consolidation function and resolution must match one model archive exactly.
For a model step of one second:
RRA::average()->steps(60)->rows(1000)
has a resolution of 60 seconds, so request:
->average() ->resolution('1m')
Grouping throws a misalignment exception
Choose an archive resolution that divides evenly into the grouping interval.
For daily grouping, one minute, 30 minutes, or one hour are sensible choices. Seven hours is not.
The current day is missing
Grouped output contains completed buckets. A current day, week, month, or year may be omitted until its closing boundary is reached.
Values are null
RRDTool represents unknown values as NaN. get() converts those values to null.
Common causes include:
- missed updates beyond the data source heartbeat;
- values outside the configured minimum or maximum;
- insufficient known primary data points for the archive's XFiles factor.
Examples
The examples/ directory contains runnable examples.
Watts chart
The examples/watts example:
- defines a power model;
- creates and seeds a local RRD file;
- fetches and groups the data;
- serves a browser chart.
Run it with:
composer install php examples/watts/fill.php php -S 127.0.0.1:8080 -t examples/watts
Then open:
http://127.0.0.1:8080
Contributing
Install development dependencies:
composer install
Run the complete local quality suite:
composer quality
Or run checks separately:
composer cs:check
composer stan
composer test
Apply coding-style fixes:
composer cs:fix
Pull requests and pushes to main are checked by GitHub Actions for coding style, PHPStan, and PHPUnit.
When changing public behaviour, update or add:
- unit tests;
- integration tests when the RRD extension is involved;
- README examples and API notes.
License
Graphy is released under the MIT License.
MIT © David Ivanov