承接 letopis/laravel-sdk 相关项目开发

从需求分析到上线部署,全程专人跟进,保证项目质量与交付效率

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

letopis/laravel-sdk

Composer 安装命令:

composer require letopis/laravel-sdk

包简介

Laravel SDK for the Letopis history-management service

README 文档

README

CI License Packagist

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

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: Apache-2.0
  • 更新时间: 2026-07-09

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固