letopis/laravel-sdk
Composer 安装命令:
composer require letopis/laravel-sdk
包简介
Laravel SDK for the Letopis history-management service
README 文档
README
Laravel SDK for Letopis — a multi-tenant entity history service. Track every change to your CRM deals, documents, orders, or any other entities.
Requirements
- PHP 8.2+
- Laravel 11 or 12
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
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
Recording changes
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
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
Letopis::ingest('crm.deals', 'd-1') ->authorId('42') ->source('crm-prod') ->delete();
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
Letopis::ingest('crm.deals', 'd-1') ->idempotencyKey('my-system-event-id-981') // or ->eventId(...) ->state([...]);
Optimistic locking
Letopis::ingest('crm.deals', 'd-1') ->expectedVersion(16) // 409 if the current version is not 16 ->state([...]);
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
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
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
$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
// 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
$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 = 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 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)
// 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
// 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
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
// 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
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
$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
Letopis::healthz(); // liveness Letopis::readyz(); // readiness (checks MongoDB + Redis) Letopis::version(); // server version info
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
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)
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
| Key | Type | Default | Description |
|---|---|---|---|
collection |
string|callable |
table name | Letopis collection name |
entity_id |
string|callable |
primary key | Attribute 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 three | Which events to observe: created, updated, deleted |
after_commit |
bool |
true |
Fire after the enclosing DB transaction commits |
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
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:
| Method | What 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
All API errors throw exceptions from the Letopis\Laravel\Exceptions namespace:
| Exception | HTTP status | When |
|---|---|---|
ValidationException |
400 | Invalid request body or parameters |
AuthenticationException |
401 | Missing or invalid API key |
AuthorizationException |
403 | Insufficient scope or collection access |
NotFoundException |
404 | Entity, collection, or ticket not found |
ConflictException |
409 | expected_version mismatch or duplicate idempotency key with different body |
PayloadTooLargeException |
413 | Event exceeds the size limit |
PluginRejectionException |
422 | Rejected by a fail-closed plugin (e.g. hash-chain) |
RateLimitException |
429 | Rate limit or backpressure; check $e->retryAfter |
ServerException |
503 | Server 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
Contributions are welcome — see CONTRIBUTING.md. This project follows a Code of Conduct; to report a security issue, see SECURITY.md rather than opening a public issue.
License
Apache 2.0 — see LICENSE and NOTICE. "Letopis" is a trademark; see the trademark guidelines.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 1
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: Apache-2.0
- 更新时间: 2026-07-09