Skip to content

Persistence

An app’s properties live in a worker process, and workers restart: after a crash, a deploy, or a change to stewart.yaml. Anything an app must remember longer goes into a store. This app counts how often the front door opens, and the count survives restarts:

apps/DoorCounter.php
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;
use Stewart\Contracts\Automation;
use Stewart\Contracts\HaContext;
use Stewart\Contracts\Store\Store;
#[Automation(id: 'door-counter')]
final class DoorCounter implements App
{
public function __construct(
private readonly HaContext $ha,
private readonly Store $store,
) {}
public function initialize(): void
{
$this->ha->watchStateChanges('binary_sensor.front_door')->whenChangedTo('on')->subscribe(function (): void {
$this->store->increment('opens');
});
}
public function dispose(): void {}
}

A Store parameter gives the app its own storage, filed under its ID. No other app can read or change it.

A new project already has it: compose.yaml runs a Valkey server, and .env points Stewart at it:

Terminal window
STEWART_PERSISTENCE__URL=redis://valkey:6379/0

Any Redis-compatible server works. A password and a database number go in the URL: redis://:secret@my-redis:6379/3. unix:///path/to/redis.sock connects over a socket. TLS (rediss://) is not supported.

Without a URL, Stewart runs, but an app that asks for a Store does not start; the log names the setting to fill in. With a URL, Stewart checks the server once at start and refuses to start if it cannot reach it.

Give Stewart a database number of its own rather than sharing one.

A key that was never written, or that has expired, reads as null:

$opens = $this->store->getInt('opens') ?? 0;
$mode = $this->store->getString('mode');
$target = $this->store->getFloat('target');
$armed = $this->store->getBool('armed');
Method Does
set('mode', 'away') Store a string, number, boolean or object
set('seen', true, Duration::minutes(10)) The same, forgotten after 10 minutes
increment('opens') / increment('budget', -5) Add to a whole number; a missing key counts as 0
has('mode') Whether the key exists
delete('mode') Remove one key
clear() Remove every key of this app
listKeys() All keys of this app, sorted
  • Reading a key as the wrong type, such as getInt() on text, throws StoreException with the reason TypeMismatch. It means something else wrote that key.
  • set() without a time limit removes the limit the key had. increment() keeps it.
  • A key is a letter or digit, then up to 127 letters, digits, ., _, : or -. Entity IDs are valid keys, and : is handy for grouping: rooms:hall:brightness.

A value with structure is a small class that implements Storable and says how it is stored:

apps/Thermostat.php
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\Store\Storable;
use UnexpectedValueException;
final readonly class Thermostat implements Storable
{
public function __construct(public float $target, public string $mode) {}
public function toStorage(): array
{
return ['target' => $this->target, 'mode' => $this->mode];
}
public static function fromStorage(array $data): static
{
$target = $data['target'] ?? null;
$mode = $data['mode'] ?? null;
if (!is_numeric($target) || !\is_string($mode)) {
throw new UnexpectedValueException('A stored thermostat needs a numeric target and a mode.');
}
return new self((float) $target, $mode);
}
}
$this->store->set('thermostat', new Thermostat(21.5, 'heat'));
$thermostat = $this->store->getObject('thermostat', Thermostat::class);
  • Only what toStorage() returns is stored, as JSON. The class name is not, so you can rename or move the class.
  • toStorage() returns named fields, not a list. Text must be UTF-8 and numbers finite.
  • When you change the fields, make fromStorage() accept the old shape too. If it throws, reading fails with the reason ValueRejected, which names the key and the class.

An attribute on the constructor parameter picks other storage:

apps/AwayMode.php
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;
use Stewart\Contracts\Automation;
use Stewart\Contracts\HaContext;
use Stewart\Contracts\Store\GlobalStore;
use Stewart\Contracts\Store\Store;
#[Automation(id: 'away-mode')]
final readonly class AwayMode implements App
{
public function __construct(
private HaContext $ha,
#[GlobalStore]
private Store $house,
private string $family = 'group.family',
) {}
public function initialize(): void
{
$this->ha->watchStateChanges($this->family)->whenChangedTo('not_home')->subscribe(function (): void {
$this->house->set('mode', 'away');
});
$this->ha->watchStateChanges($this->family)->whenChangedTo('home')->subscribe(function (): void {
$this->house->set('mode', 'home');
});
}
public function dispose(): void {}
}
Parameter Gets
Store $store This app’s own storage
ReadableStore $store This app’s own storage, read-only
#[GlobalStore] Store $house Storage every app shares; use it for facts about the house
#[GlobalStore] ReadableStore $house The shared storage, read-only
#[PeerStore('away-mode')] ReadableStore $away Another app’s storage, read-only

#[PeerStore] must be typed ReadableStore, so each key has a single writer: the app it belongs to. A peer ID that does not exist stops the app at start, and the error suggests the closest ID.

A service from services.php that asks for a Store gets the shared storage, because a service belongs to no app.

A failed store call throws StoreException, with the reason Unreachable, Refused, TimedOut or RecentlyFailed. In a handler, Stewart logs it as a failure and the app keeps running. In initialize(), the app does not start.

After a timeout or a lost connection, a worker fails every store call at once for persistence.recovery_interval (5 seconds) with RecentlyFailed, so an outage costs one timeout, not one per handler. make status shows the store as down meanwhile.

Two things to know before relying on a write:

  • increment() is safe against other writers, but a dropped connection is retried, so it can count twice. Use it for counting, not for billing.
  • A write that throws may still have been saved.

Values are stored as plain text and JSON, so you can read them with valkey-cli:

stewart:app:door-counter:opens -> 42
stewart:app:heating:thermostat -> {"target":21.5,"mode":"heat"}
stewart:global:mode -> "away"
Terminal window
docker compose exec valkey valkey-cli --scan --pattern 'stewart:*'

stewart is persistence.prefix. Change it when two Stewart installations share one server.

The test harness gives apps an in-memory store. $harness->stores->openAppStore(new AppId('…')) and openGlobalStore() read and seed it:

tests/AwayModeTest.php
<?php
declare(strict_types=1);
namespace App\Tests;
use App\AwayMode;
use PHPUnit\Framework\TestCase;
use Stewart\Testing\App\AppHarness;
final class AwayModeTest extends TestCase
{
public function testTheHouseModeIsSharedWithEveryApp(): void
{
$harness = new AppHarness();
$harness->ha->seedState('group.family', 'home');
$harness->startApp(AwayMode::class);
$harness->ha->pushState('group.family', 'not_home');
self::assertSame('away', $harness->stores->openGlobalStore()->getString('mode'));
}
}

Persistence is not configured. Set STEWART_PERSISTENCE__URL in .env.

Stewart does not start: the store is unreachable. Check that the valkey container runs (docker compose ps) and that the URL names it.

Key "…" holds …, not …. Two apps, or two versions of one app, wrote different types under the same key.