PHPackages                             letopis/laravel-sdk - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. letopis/laravel-sdk

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

letopis/laravel-sdk
===================

Laravel SDK for the Letopis history-management service

v0.1.0(1mo ago)00Apache-2.0PHPPHP ^8.2CI failing

Since Jul 9Pushed 1mo agoCompare

[ Source](https://github.com/max-trifonov/letopis-laravel-sdk)[ Packagist](https://packagist.org/packages/letopis/laravel-sdk)[ Docs](https://letopis.tech)[ RSS](/packages/letopis-laravel-sdk/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (4)Versions (2)Used By (0)

Letopis Laravel SDK
===================

[](#letopis-laravel-sdk)

[![CI](https://github.com/max-trifonov/letopis-laravel-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/max-trifonov/letopis-laravel-sdk/actions/workflows/ci.yml)[![License](https://camo.githubusercontent.com/b29de0acdfd19013f1f02689b15c933e4a6c145be9efa718288f88ba3280b1c5/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d417061636865253230322e302d626c75652e737667)](LICENSE)[![Packagist](https://camo.githubusercontent.com/00a380b0517432c0c3655de477defd3d986eb928d1ab62b050bd50b78e30d2e1/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6c65746f7069732f6c61726176656c2d73646b2e737667)](https://packagist.org/packages/letopis/laravel-sdk)

Laravel SDK for [Letopis](https://letopis.tech) — a multi-tenant entity history service. Track every change to your CRM deals, documents, orders, or any other entities.

Requirements
------------

[](#requirements)

- PHP 8.2+
- Laravel 11 or 12

Installation
------------

[](#installation)

```
composer require letopis/laravel-sdk
```

Publish the config file:

```
php artisan vendor:publish --tag=letopis-config
```

Set your credentials in `.env`:

```
LETOPIS_BASE_URL=https://your-letopis-instance.example.com
LETOPIS_API_KEY=hm_live_...
LETOPIS_SOURCE=my-laravel-app   # optional, defaults to APP_NAME
```

The service provider and `Letopis` facade are registered automatically via package discovery.

Configuration
-------------

[](#configuration)

`config/letopis.php`:

```
return [
    'base_url'     => env('LETOPIS_BASE_URL'),
    'api_key'      => env('LETOPIS_API_KEY'),

    // Identifies which service is writing the history.
    // Included automatically in every ingest request and activity.
    // Override per-request with ->source('...').
    'source' => env('LETOPIS_SOURCE', env('APP_NAME', 'laravel')),

    // Default reliability mode: strict | durable | fast
    // null = use the server's collection-level default (recommended)
    'default_mode' => env('LETOPIS_MODE'),

    'timeout' => 30,

    'retry' => [
        'times' => 3,
        'sleep' => 200,  // ms between retries
    ],

    // Eloquent model observers — see "Eloquent Observer" section below
    'observe' => [],
];
```

Usage
-----

[](#usage)

### Recording changes

[](#recording-changes)

#### Full state (server computes the diff)

[](#full-state-server-computes-the-diff)

```
use Letopis\Laravel\Facades\Letopis;

$response = Letopis::ingest('crm.deals', 'd-1')
    ->authorId('42')
    ->source('crm-prod')
    ->tsSource('2026-06-11T10:00:00Z')
    ->meta(['ip' => '10.1.2.3'])
    ->state([
        'title'  => 'Deal #1',
        'amount' => 250,
        'status' => 'open',
    ]);

// $response->isAccepted() — true when mode is durable/fast (202)
// $response->isCreated()  — true when mode is strict (201)
// $response->ticketId     — present on 202
// $response->version      — present on 201
```

#### Pre-computed diff

[](#pre-computed-diff)

```
use Letopis\Laravel\Data\Change;

$response = Letopis::ingest('crm.deals', 'd-1')
    ->authorId('42')
    ->diff([
        Change::change('amount', 100, 250),
        Change::add('tags.0', 'vip'),
        Change::remove('items.2', ['sku' => 'X1']),
    ]);
```

#### Delete

[](#delete)

```
Letopis::ingest('crm.deals', 'd-1')
    ->authorId('42')
    ->source('crm-prod')
    ->delete();
```

#### Reliability mode per request

[](#reliability-mode-per-request)

```
// Override the server default for this request only
Letopis::ingest('crm.deals', 'd-1')->strict()->state([...]);   // synchronous write, 201
Letopis::ingest('crm.deals', 'd-1')->durable()->state([...]);  // Redis Streams queue, 202
Letopis::ingest('crm.deals', 'd-1')->fast()->state([...]);     // in-memory queue, 202
```

#### Idempotency

[](#idempotency)

```
Letopis::ingest('crm.deals', 'd-1')
    ->idempotencyKey('my-system-event-id-981')  // or ->eventId(...)
    ->state([...]);
```

#### Optimistic locking

[](#optimistic-locking)

```
Letopis::ingest('crm.deals', 'd-1')
    ->expectedVersion(16)  // 409 if the current version is not 16
    ->state([...]);
```

#### Linking to a business flow

[](#linking-to-a-business-flow)

```
Letopis::ingest('crm.deals', 'd-1')
    ->flow(flowId: 'f_01J...', causedBy: [['activity_id' => 'act-7']], step: 'deal-approved')
    ->state([...]);
```

---

### Batch ingest

[](#batch-ingest)

Send up to 1 000 events across different collections in a single request. Invalid items are rejected synchronously; the rest are processed normally (partial acceptance).

```
$result = Letopis::batch()
    ->addState('crm.deals',     'd-1', ['amount' => 100], ['author_id' => '42'])
    ->addState('crm.deals',     'd-2', ['amount' => 200])
    ->addDiff('crm.contracts',  'c-5', [Change::change('status', 'draft', 'signed')->toArray()])
    ->addDelete('crm.leads',    'l-9')
    ->send();

$result->ticketId;   // umbrella ticket
$result->accepted;   // number of accepted items
$result->rejected;   // BatchReject[]

foreach ($result->rejected as $reject) {
    echo "Item {$reject->index} failed: [{$reject->errorCode}] {$reject->errorMessage}\n";
}
```

You can also add raw event arrays directly:

```
Letopis::batch()->add('crm.deals', 'd-1', 'state', [
    'author_id' => '42',
    'state'     => ['amount' => 100],
])->send();
```

---

### Reading history

[](#reading-history)

#### Event log

[](#event-log)

```
$history = Letopis::history('crm.deals', 'd-1')
    ->limit(50)
    ->from('2026-01-01T00:00:00Z')
    ->to('2026-06-30T23:59:59Z')
    ->authorId('42')
    ->op('update')          // create | update | delete
    ->path('amount')        // filter by changed field (glob supported: items.*.price)
    ->orderBy('version', 'desc')
    ->get();

foreach ($history->events as $event) {
    echo "v{$event->version} by {$event->authorId}: {$event->op}\n";
    foreach ($event->changes as $change) {
        echo "  {$change['path']}: {$change['old']} → {$change['new']}\n";
    }
}

// Cursor-based pagination
if ($history->hasMore()) {
    $next = Letopis::history('crm.deals', 'd-1')
        ->cursor($history->nextCursor)
        ->get();
}
```

#### Current state

[](#current-state)

```
$state = Letopis::state('crm.deals', 'd-1');

echo $state->version;   // current version number
echo $state->deleted;   // true if the entity was deleted
print_r($state->state); // the entity's current field values
```

#### Point-in-time state

[](#point-in-time-state)

```
// State at a specific version
$state = Letopis::stateAtVersion('crm.deals', 'd-1', 12);

// State at a specific point in time
$state = Letopis::stateAt('crm.deals', 'd-1', '2026-06-01T00:00:00Z');

// How the state was reconstructed
echo $state->snapshotVersion; // snapshot used as base (null = full replay from genesis)
echo $state->eventsApplied;   // events applied on top of the snapshot
```

---

### Async ticket status

[](#async-ticket-status)

```
$response = Letopis::ingest('crm.deals', 'd-1')->durable()->state([...]);

$ticket = Letopis::ticket($response->ticketId);

echo $ticket->status;   // accepted | processing | stored | failed | partial

if ($ticket->isFailed()) {
    echo $ticket->error;
}
```

---

### Collections

[](#collections)

```
$collections = Letopis::collections();

foreach ($collections->collections as $col) {
    echo "{$col->name}: {$col->entities} entities, {$col->events} events\n";
    echo "Last activity: {$col->lastEventAt}\n";
}
```

---

### Activities and flows

[](#activities-and-flows)

Activities are business events that are not entity changes (e.g. "recalculated prices", "sent notification"). They can reference entities and form a causal DAG with the `caused_by` field.

```
// Record an activity
$response = Letopis::activity()
    ->type('recalc.prices')
    ->authorId('42')
    ->source('billing-svc')
    ->tsSource('2026-06-11T10:00:05Z')
    ->flowId('f_01J...')        // omit to let the server create a new flow
    ->causedBy([
        ['collection' => 'crm.deals', 'entity_id' => 'd-1', 'event_id' => 'src-evt-981'],
    ])
    ->refs([
        ['collection' => 'crm.deals',  'entity_id' => 'd-1'],
        ['collection' => 'crm.prices', 'entity_id' => 'p-44'],
    ])
    ->data(['recalced' => 17, 'duration_ms' => 840])
    ->create();

echo $response->activityId;
echo $response->flowId;
```

```
// Read a flow (all events + activities, as a DAG)
$flow = Letopis::flow('f_01J...');

foreach ($flow->nodes as $node) {
    if ($node->isEvent()) {
        echo "Event v{$node->version} on {$node->collection}/{$node->entityId}\n";
    } else {
        echo "Activity: {$node->type}\n";
    }
}
```

```
// List flows that touched an entity
$flows = Letopis::entityFlows('crm.deals', 'd-1');
```

---

### Rules (webhook triggers)

[](#rules-webhook-triggers)

```
// Create a rule
Letopis::rules('crm.deals')->create([
    'name'    => 'alert-on-amount-drop',
    'enabled' => true,
    'condition' => [
        'all' => [
            ['field' => 'op', 'eq' => 'update'],
            ['field' => 'changes', 'match' => [
                'path' => 'status', 'old' => 'active', 'new' => 'cancelled',
            ]],
        ],
    ],
    'actions' => [
        [
            'type'       => 'webhook',
            'url'        => 'https://your-app.example.com/hooks/letopis',
            'secret_ref' => 'whsec_1',
            'timeout_ms' => 5000,
            'retry'      => ['max_attempts' => 8, 'backoff' => 'exponential'],
        ],
    ],
]);

Letopis::rules('crm.deals')->list();
Letopis::rules('crm.deals')->get('rule_01J...');
Letopis::rules('crm.deals')->update('rule_01J...', ['enabled' => false]);
Letopis::rules('crm.deals')->delete('rule_01J...');
```

#### Dead-letter queue

[](#dead-letter-queue)

```
// View failed deliveries
$dlq = Letopis::rules('crm.deals')->dlq('rule_01J...');

// Redeliver all
Letopis::rules('crm.deals')->redeliver('rule_01J...');

// Redeliver specific items
Letopis::rules('crm.deals')->redeliver('rule_01J...', ['dlq_item_id_1', 'dlq_item_id_2']);
```

---

### Verifying webhook signatures

[](#verifying-webhook-signatures)

Letopis signs every outgoing webhook with HMAC-SHA256. Verify it before processing:

```
use Letopis\Laravel\Webhooks\WebhookSignature;

// In your controller / route:
$rawBody  = $request->getContent();
$sigHeader = $request->header('X-HM-Signature');

try {
    WebhookSignature::verify(
        secret: config('services.letopis_webhook_secret'),
        rawBody: $rawBody,
        header: $sigHeader,
    );
} catch (\Letopis\Laravel\Exceptions\LetopisException $e) {
    abort(401, 'Invalid webhook signature');
}

$payload = $request->json()->all();
// process $payload...
```

---

### Collection configuration

[](#collection-configuration)

```
// Read current config (default values are annotated by the server)
$config = Letopis::collectionConfig('crm.deals')->get();

// Update
Letopis::collectionConfig('crm.deals')->put([
    'reliability_mode'   => 'strict',
    'snapshot_interval'  => 50,
    'max_event_size_bytes' => 524288,
    'plugins' => ['hash_chain' => ['enabled' => true]],
]);
```

---

### Hash-chain integrity

[](#hash-chain-integrity)

When the `hash_chain` plugin is enabled, every event carries a cryptographic hash chaining it to the previous one. Use `:verify` to detect tampering:

```
// Verify a single entity
$result = Letopis::verify('crm.deals', 'd-1');

if (!$result->valid) {
    echo "Chain broken at version {$result->brokenAtVersion}";
}

// Verify an entire collection (async, returns a ticket)
$result = Letopis::verifyCollection('crm.deals');
echo $result->ticketId;
```

---

### Administration

[](#administration)

```
$admin = Letopis::admin();

// Tenants
$admin->listTenants();
$admin->createTenant(['id' => 'acme', 'database' => ['uri' => 'mongodb://...']]);
$admin->deleteTenant('acme');

// API keys
$admin->listKeys();
$key = $admin->createKey([
    'tenant'      => 'acme',
    'scopes'      => ['write', 'read'],
    'collections' => ['crm.*'],
]);
echo $key['key'];  // shown once, store it securely
$admin->deleteKey($key['id']);

// Right to be forgotten (GDPR)
$admin->purgeEntity('crm.deals', 'd-1');
$admin->purgeCollection('crm.deals');
```

---

### Health checks

[](#health-checks)

```
Letopis::healthz();  // liveness
Letopis::readyz();   // readiness (checks MongoDB + Redis)
Letopis::version();  // server version info
```

---

Eloquent Observer
-----------------

[](#eloquent-observer)

The SDK can automatically record Eloquent model changes without adding manual `Letopis::ingest(...)` calls throughout your codebase. Two approaches are available — pick whichever fits best, or mix them.

### Option A — Trait on the model

[](#option-a--trait-on-the-model)

Add `HasLetopisHistory` to the model. All defaults are sensible out of the box; override individual methods only when needed.

```
use Letopis\Laravel\Concerns\HasLetopisHistory;

class Deal extends Model
{
    use HasLetopisHistory;

    // --- Override only what you need ---

    // Collection name (default: table name, e.g. "deals")
    public function letopisCollection(): string
    {
        return 'crm.deals';
    }

    // Author ID attached to every event (default: null)
    public function letopisAuthorId(): ?string
    {
        return auth()->id() ? (string) auth()->id() : null;
    }

    // Attributes to skip (default: [])
    public function letopisExclude(): array
    {
        return ['updated_at', 'remember_token'];
    }

    // Extra metadata attached to every event (default: [])
    public function letopisMeta(): array
    {
        return ['tenant' => tenant()->id];
    }

    // Reliability mode override (default: SDK config default_mode)
    public function letopisMode(): ?string
    {
        return 'durable';
    }

    // Which events to observe (default: all three)
    public function letopisEvents(): array
    {
        return ['created', 'updated', 'deleted'];
    }

    // Source override (default: global config 'source')
    public function letopisSource(): ?string
    {
        return null;
    }
}
```

All lifecycle events fire **after the DB transaction commits** (`afterCommit: true`) so rolled-back writes are never recorded.

### Option B — Config-driven (no model changes)

[](#option-b--config-driven-no-model-changes)

List model classes in `config/letopis.php`. Useful when you cannot or do not want to touch model files (third-party packages, legacy code, etc.).

```
// config/letopis.php
'observe' => [
    \App\Models\Deal::class => [
        'collection' => 'crm.deals',
        'entity_id'  => 'id',                          // attribute name or callable
        'exclude'    => ['updated_at', 'remember_token'],
        'author_id'  => fn ($m) => auth()->id(),       // callable receives the model
        'mode'       => 'durable',
        'meta'       => fn ($m) => ['ip' => request()->ip()],
        'after_commit' => true,
    ],

    \App\Models\Contract::class => [
        'collection' => 'docs.contracts',
        'entity_id'  => fn ($m) => "contract-{$m->uuid}",
        'exclude'    => ['updated_at'],
        'events'     => ['created', 'updated'],        // skip delete recording
    ],
],
```

### Config reference

[](#config-reference)

KeyTypeDefaultDescription`collection``string|callable`table nameLetopis collection name`entity_id``string|callable`primary keyAttribute name or `fn($model): string``source``string|callable|null`global `source`Overrides the SDK-wide source for this model`author_id``callable|null``null``fn($model): ?string` — who made the change`mode``'strict'|'durable'|'fast'|null``null`Reliability mode override`exclude``string[]``[]`Attribute names to omit from change recording`meta``array|callable``[]`Extra metadata or `fn($model): array``events``string[]`all threeWhich events to observe: `created`, `updated`, `deleted``after_commit``bool``true`Fire after the enclosing DB transaction commits### Soft deletes

[](#soft-deletes)

When a model uses `SoftDeletes`, Eloquent fires `updated` (sets `deleted_at`) rather than `deleted`. The observer records this as a normal field change. If you want to record it as a Letopis delete event instead, override `letopisEvents` and handle the soft-delete explicitly:

```
public function letopisEvents(): array
{
    return ['created', 'updated'];  // suppress the hard-delete hook
}

protected static function booted(): void
{
    static::softDeleted(function (self $model) {
        Letopis::ingest($model->letopisCollection(), $model->letopisEntityId())
            ->authorId($model->letopisAuthorId() ?? '')
            ->delete();
    });
}
```

---

Testing
-------

[](#testing)

Call `Letopis::fake()` at the start of a test. It swaps the facade binding, intercepts all HTTP calls, and returns sensible default responses.

```
use Letopis\Laravel\Facades\Letopis;
use Letopis\Laravel\Data\Change;

class DealServiceTest extends TestCase
{
    public function test_closing_a_deal_records_the_state_change(): void
    {
        $fake = Letopis::fake();

        // Run the code under test
        app(DealService::class)->close('d-1');

        // Assert what was sent to Letopis
        $fake->assertIngestedState('crm.deals', 'd-1');
    }

    public function test_batch_import_sends_all_events(): void
    {
        $fake = Letopis::fake();

        app(DealImporter::class)->import($deals);

        $fake->assertBatchSent();
    }

    public function test_activity_is_recorded_after_recalc(): void
    {
        $fake = Letopis::fake();

        app(PriceRecalculator::class)->run();

        $fake->assertActivityCreated('recalc.prices');
    }

    public function test_no_history_calls_on_cache_hit(): void
    {
        $fake = Letopis::fake();

        app(DealRepository::class)->findCached('d-1'); // should use cache, not Letopis

        $fake->assertNothingSent();
    }
}
```

Available assertions:

MethodWhat it checks`assertIngestedState($col, $eid)`A state POST was sent for the entity`assertIngestedDiff($col, $eid)`A diff POST was sent for the entity`assertDeleted($col, $eid)`A delete POST was sent for the entity`assertBatchSent()`A `/events:batch` request was sent`assertActivityCreated(?$type)`An activity was created (optionally by type)`assertNothingSent()`No requests were made at all`recordedRequests()`Returns all recorded `Request` objects for custom assertions---

Error handling
--------------

[](#error-handling)

All API errors throw exceptions from the `Letopis\Laravel\Exceptions` namespace:

ExceptionHTTP statusWhen`ValidationException`400Invalid request body or parameters`AuthenticationException`401Missing or invalid API key`AuthorizationException`403Insufficient scope or collection access`NotFoundException`404Entity, collection, or ticket not found`ConflictException`409`expected_version` mismatch or duplicate idempotency key with different body`PayloadTooLargeException`413Event exceeds the size limit`PluginRejectionException`422Rejected by a fail-closed plugin (e.g. hash-chain)`RateLimitException`429Rate limit or backpressure; check `$e->retryAfter``ServerException`503Server or database unavailable`LetopisException`—Base class for all of the above```
use Letopis\Laravel\Exceptions\ConflictException;
use Letopis\Laravel\Exceptions\RateLimitException;
use Letopis\Laravel\Exceptions\LetopisException;

try {
    Letopis::ingest('crm.deals', 'd-1')
        ->expectedVersion(16)
        ->state([...]);
} catch (ConflictException $e) {
    // version mismatch — reload the entity and retry
} catch (RateLimitException $e) {
    // $e->retryAfter — seconds to wait before retrying
    sleep($e->retryAfter);
} catch (LetopisException $e) {
    Log::error('Letopis error', ['code' => $e->errorCode, 'status' => $e->getHttpStatus()]);
}
```

Contributing
------------

[](#contributing)

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). This project follows a [Code of Conduct](CODE_OF_CONDUCT.md); to report a security issue, see [SECURITY.md](SECURITY.md) rather than opening a public issue.

License
-------

[](#license)

Apache 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE). "Letopis" is a trademark; see the [trademark guidelines](https://github.com/max-trifonov/letopis/blob/main/TRADEMARK.md).

###  Health Score

34

—

LowBetter than 74% of packages

Maintenance90

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity36

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

47d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/7e1eb50d83a8ea160357bd462d27b4257b483398a2572045c8e6545fe1e0ab8e?d=identicon)[Nidavellir](/maintainers/Nidavellir)

---

Top Contributors

[![Nidavellir-Lab](https://avatars.githubusercontent.com/u/190834297?v=4)](https://github.com/Nidavellir-Lab "Nidavellir-Lab (3 commits)")

---

Tags

laravelhistoryaudit-logletopis

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/letopis-laravel-sdk/health.svg)

```
[![Health](https://phpackages.com/badges/letopis-laravel-sdk/health.svg)](https://phpackages.com/packages/letopis-laravel-sdk)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.4M354](/packages/psalm-plugin-laravel)[api-platform/laravel

API Platform support for Laravel

58190.1k21](/packages/api-platform-laravel)[forjedio/inertia-table

Backend-driven dynamic tables for Laravel + Inertia.js

272.0k](/packages/forjedio-inertia-table)[zidbih/laravel-deadlock

Make temporary Laravel workarounds expire and fail CI when ignored.

1007.4k](/packages/zidbih-laravel-deadlock)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
