Skip to content

Recording activity

tilttek/activity

An activity

An Activity is a fact: this member made this gesture, on this object, at this moment.

PartTypeRule
keyIdempotencyKey1 to 255 bytes, no whitespace or control characters
memberResourceReferenceThe member, as the application references it: member:44
verbVerbLower-case segments of letters, digits and _, each starting with a letter, joined by dots; at most 128 bytes
occurredAtDateTimeImmutableWhen the gesture happened, which is not always when it is recorded; between the years 1970 and 9999, in UTC
object?ResourceReferenceWhat the gesture was about — a game, a comment, an order, another member — or nothing
quantityintHow much of the gesture: one game, three pages, an order of 42; from 1 to 1 000 000 000
contextContextUp to 16 named integers, booleans or strings of at most 100 bytes, which the rules may read
use Tilttek\Activity\Activity;

$activity = Activity::of(
    key: 'order:2026-0193:paid',
    member: 'member:44',
    verb: 'order.paid',
    occurredAt: $paidAt,
    object: 'order:2026-0193',
    quantity: 42,
    context: ['channel' => 'web', 'first_order' => true],
);

Activity::of() takes plain values; the constructor takes the value objects.

An activity carries no text of the member. A comment is the activity's object, by reference, and the context holds facts the application sets — a game system, a level, a flag —, never what the member typed. This keeps the journal out of the way of a moderation or an edit, and keeps its export short.

The idempotency key

The same key counts once. Recording an activity whose key is already in the journal returns the entry already there, with Appended::$new false, and tells no observer. Build the key from what makes the gesture unique, never from the time it is recorded:

GestureA good key
A comment postedcomment:4512
An order paidorder:2026-0193:paid
A game played by one of its playersgame:812:player:44
A daily visitvisit:member:44:2026-11-07

The same key for a different gesture — another member, verb, object, quantity or context — raises IdempotencyConflict: it is a bug in how the application builds its keys, and recording it would lose one of the two gestures. The moment is left out of the comparison, since a task run again may stamp it again.

The moment

The journal records a gesture dated within its window: at most thirty days before its clock and five minutes after it, by default. Outside, Journal::record() raises InvalidActivity and records nothing. A moment the client gives, for an action made offline, can then neither reach back to collect the caps of days gone by, nor sit so far from now that a streak or a period counts its units by the billion.

use DateInterval;
use Tilttek\Activity\Journal;

$journal = new Journal($log, $clock, past: new DateInterval('P7D'), ahead: new DateInterval('PT1M'));
$importer = new Journal($log, $clock, past: null); // a history imported once, with no bound in the past

Cancelling

$journal->cancel('order:2026-0193:paid'); // the order was refunded

A cancellation is an entry of its own, at the next position, carrying the cancelled activity: nothing in the journal is rewritten, and a reader replays it as the reverse of the activity. Cancelling twice returns the first cancellation; cancelling a key that was never recorded returns null. A cancelled activity cannot be recorded again under the same key: a new gesture takes a new key.

Declared verbs

$journal = new Journal($log, $clock, Verbs::of(['news.commented', 'game.*', 'order.paid']));

With declared verbs, an activity whose verb no pattern matches raises UnknownVerb before anything is written: most often a typing error that would record a gesture no rule ever reads. Without patterns, every verb is accepted.

Observers

A JournalObserver is told of each new entry, recorded or cancelled, right after it is written, in the same process; a duplicate is not told. An observer that fails lets its exception through, after the entry is written. LiveProjections is the observer that keeps the projections up to date (Projections).