Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Prism Harness Laravel Package

particle-academy/prism-harness

A testing harness for Laravel's Prism AI package. Fake model responses, record and replay AI interactions, and assert prompts, tool calls, and outputs in your test suite without hitting real APIs or burning tokens.

View on GitHub
Deep Wiki
Context7

Getting Started

  • Update: composer require prism/harness (v0.15.0 is additive — no breaking changes, existing capped runs without an observer continue to work).
  • Publish the config if you haven't: php artisan vendor:publish --tag=prism-harness-config. The relevant key for this release is prism-harness.cost_observer — a container-resolved class name used only as a fallback when a run tree has no observer.
  • Look first at four things: the contract Prism\Harness\Contracts\CallCostObserver, the DTOs CallReservation (callId, provider, model, worstCaseUsd, kind) and CallSettlement (callId, chargedUsd, usageKnown, ?Usage, outcome), and RunContext::root(...).
  • Your first use case: a shared, cross-worker account of spend that can veto a call before the provider is hit.
use Prism\Harness\RunContext;

$run = RunContext::root(
    budgetCapUsd: 25.00,          // required when an observer is attached
    observer: new \App\Harness\SpendLedgerObserver(),
);

$response = Prism::text()
    ->using('anthropic', 'claude-sonnet-4-5')
    ->withPrompt('...')
    ->run($run);                 // or ->send/->stream/->sendStructured($run)

Implementation Patterns

Observer as a ledger mirror. Back the observer with a table or Redis key keyed by callId. reserving() writes a pending row with worstCaseUsd; settled() flips it to settled with chargedUsd. Because the harness charges exactly the reserved maximum when usage is unknown, your row and the harness ledger agree without you recomputing anything — never derive cost yourself.

Use reserving() for policy, not accounting. This is the only place you can refuse. Throw CallRefused('org_budget_exhausted') for a clean, typed refusal; the call is never sent, the reservation is released without charge, and the run fails with RunNotPermitted. Anything else you throw becomes call_refused_by_host with your exception chained as previous — fine for genuine errors, bad as a control-flow signal.

public function reserving(CallReservation $call, RunContext $run): void
{
    if ($this->balance($run) < $call->worstCaseUsd) {
        throw new CallRefused('org_budget_exhausted');
    }
    $this->store->reserve($call->callId, $call->worstCaseUsd, $call->kind);
}

settled() must be defensive. It fires exactly once per admitted call — completed, failed, or a stream the caller stopped reading — which is precisely what makes it the right place to close the row. But if it throws, the harness settlement still stands and the exception propagates out of your application code. Wrap the body in try/catch, log, and return; never let bookkeeping break a completed request.

Make it idempotent on callId. Use insertOrIgnore / SETNX-style upserts. A retried job, a queue restart, or a duplicated settlement callback should be a no-op, not a double charge.

Bucket by kind. text, structured, and stream are distinguishable at reserve time, and the same observer covers send, sendStructured, stream, plus approve/deny/decide continuations. Streaming is the interesting one: settled() still fires when the consumer disconnects mid-stream, so you get a truthful number for a request nobody finished reading.

Set the observer on the root, once. Children inherit and a run tree cannot drop or replace it. Fan-out workers should be spawned beneath the root that already carries the observer, not created as fresh roots.

$child = $run->child(...);   // inherits $observer

Prefer the config fallback for ambient observers (metrics, tracing) and explicit injection for tests. Because the config value is container-resolved, $this->app->instance(SpendLedgerObserver::class, $fake) gives you a fake everywhere without touching call sites.

Testing shape: an in-memory observer asserting reserving then settled per call, with chargedUsd matching the reservation, is a one-class integration test that covers the whole capped path — including the refusal case.

Gotchas and Tips

  • budget_observer_uncapped. Attaching an observer to a run with no cost cap is refused outright. The rationale is sound (only capped runs serialize one model call at a time), but it bites in tests and ad-hoc scripts: always pass a cap, even an absurd one like 9999.00.
  • Reservations leak on worker death. If a process dies between reserving() and settled(), your row stays open forever — the harness has no reaper, because it does not own your storage. Build a TTL sweep: expire pending reservations older than your longest plausible call and reconcile against the harness ledger rather than assuming the worst.
  • Refusal codes are constrained. CallRefused codes must be lowercase and ≤64 characters. Violating that silently degrades to call_refused_by_host, which is a miserable thing to debug from a log line.
  • Don't assume usage exists. usageKnown can be false and Usage is nullable; chargedUsd is then the reserved maximum. Dashboards reading usage->totalTokens will NPE on exactly the calls you most want to see.
  • The observer is on the hot path, pre-request. reserving() runs after admission and before any provider call, so every millisecond is added to your first-token latency. Keep it to a single indexed upsert; push analytics to a queue.
  • Trusted code means unmetered code. The harness does not meter or sandbox the observer. It can also hold a lock across the call if you're careless — do not take a transaction that spans from reserving() to settled().
  • settled() failures are visible but not recoverable. Because the exception propagates after settlement is already committed, a try { ... } finally in your controller will see an exception whose underlying charge succeeded. Catch inside the observer.
  • Correlate on callId. It is identical at reserve and settle; log it in both, and alert on reservations with no matching settlement older than N minutes — that is your crash detector, and it doubles as the leak detector from the point above.
  • Debugging a refusal: the run fails with RunNotPermitted, and the cause is either your CallRefused code or call_refused_by_host with your original exception as previous. Check previous first when the code is generic.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource