spectra-php/laravel-spectra
Laravel Spectra is the Laravel integration for the Spectra PHP package. It provides config publishing, service-provider registration, and framework-friendly helpers so you can add Spectra to Laravel apps with minimal setup and familiar conventions.
Spectra is zero-instrumentation AI usage tracking for Laravel: it hooks the HTTP stack your AI calls already go through (Laravel HTTP client, Prism, Laravel AI SDK, openai-php, Guzzle), prices each call from a shipped catalog, stores a row per request, and gives you budgets plus a dashboard.
Before you install v2 (currently 2.0.1):
UPGRADE.md before composer update. Several fixes change stored values (token counts, cost, daily-stat totals) rather than just code paths, so you want to know what your history will look like afterwards.cached_tokens, cache_write_tokens, page_count, currency, error_type, error_message, parent_span_id, conversation_id, conversation_turn, request_source, laravel_request_id, ip_address, user_agent. Re-publish the config too and diff it against yours. If you point spectra.storage.connection at a dedicated connection, read the migrate:fresh gotcha below before you run it — 2.0.1 makes the migrations idempotent, which fixes the failure but also silences a signal you may want.Where to look first:
config/spectra.php — specifically tracking.trace_per_request, tracking.use_traceparent, prompt storage, budgets, storage.connection, and the new spectra.costs.exchange_rates block.First use case: install, migrate, then make one real streaming call through your existing client — no code changes. Open the dashboard, click into the request, and confirm three things: the model resolved (not "unknown"), the token breakdown is non-zero on the streamed call, and the cost is non-zero when you know the call was billable. If those three hold, the integration is working.
For local development, vendor/bin/testbench workbench:build seeds demo data so you can work on the dashboard without burning real API credits. That is a workbench-only command — never wire it into an app.
Automatic tracking is the default path — resist wrapping it. v2 tracks streams as the app consumes them, including SSE, newline-delimited JSON, streamed JSON arrays, streams the app stops reading early, and error events sent mid-stream. If you wrote any "buffer the stream so Spectra sees the body" workaround in 1.x, delete it; that pattern is exactly the bug v2 fixes.
Trace per request is the new organising concept. All AI calls inside one HTTP request, queued job, or Artisan command share a trace id, jobs dispatched during a request inherit it, and an inbound W3C traceparent is joined with its span as the OpenTelemetry parent. Day-to-day usage:
Spectra::currentTraceId() when you want to attach the id to your own logs or error reports, so a Sentry event and a dashboard trace line up.Spectra::newTrace() in long-running workers, queue consumers, or anywhere the "request" boundary is blurry, when you want a fresh logical unit rather than inheriting whatever came before.tracking.trace_per_request and tracking.use_traceparent deliberately. If your app sits behind an existing observability pipeline that already emits traceparent, use_traceparent lets you join traces rather than run two parallel id spaces.Scope your tracking context explicitly — this is the pattern v2 forces on you. Spectra is now a scoped service. Global tags, trackable, pricing tier, and metadata no longer leak across queued jobs and Octane requests, which is a real correctness win, but it means you can no longer set them once in a service provider and assume they persist. The correct shape is:
handle() or each controller action, right before the AI call.AppServiceProvider::boot().Per-user and per-tenant budgets. Budgets resolve through the trackable, so the trackable must be set before the call and must match what you query later. With a morph map in play, v2 stores trackables consistently — confirm your budgets actually resolve after upgrading (see Gotchas).
Queued and async work. Polled async jobs (video, background responses, predictions, queued jobs) now add only the delta per repeat write instead of re-counting the whole job on every poll. If you have your own polling loop that re-reads a prediction every few seconds, keep it — it is now cheap in stats and budget terms.
Pricing configuration as a first-class concern. v2 adds cache writes, long-context pricing (aboveInputTokens), date-effective prices (since), audio and image token rates, fast/ultrafast tiers, megapixel/page/request units, aliases, retired models, per-catalog currency with exchange rates, provider-reported costs, and regional surcharges. Practical consequences:
spectra.costs.exchange_rates if you have catalogs in different currencies — otherwise your aggregate spend numbers are mixing units.Storage topology is a config decision, not an afterthought. spectra.storage.connection lets you keep Spectra's tables somewhere other than your default connection. That is a reasonable choice for a high-write analytics workload, but it splits your migration story in two: your normal php artisan migrate flow covers both, while migrate:fresh, migrate:refresh, and any DB-reset tooling generally do not. Treat the Spectra connection as a first-class part of your deploy and reset scripts, not an extra.
Pruning and retention. Schedule spectra:prune in the console kernel with --stats-days to keep daily stats longer than raw requests. Prune now deletes stored media and works in chunks, so it is safe on large tables but takes time — run it off-peak. Confirm the configured disk permissions allow media deletion, especially if media lives off the default disk.
Dashboard as the debugging surface. The new request detail page shows token breakdown, error details, request origin, and a Trace card with every AI call of the same request as a timeline with total cost, tokens, and time. When a cost looks wrong, start there before reading rows.
Your historical numbers are now wrong in specific, known ways. This is the biggest thing to communicate to stakeholders:
input_tokens (which already excludes them) and cache writes were never billed at all. If you run heavy Anthropic caching, expect a visible step up in reported cost that is a correction, not a regression.When you roll v2 out, annotate the dashboard or your internal reports with the upgrade date so nobody reads the discontinuity as a real spend spike.
The scoped-service change is the most likely source of silent breakage. If any of your code (or a package) does Spectra::tags([...]), sets a trackable, or sets a pricing tier in a service provider, a middleware that only runs once, or a singleton boot path, it will now not apply where you expect — especially under Octane or in queue workers. Audit for those calls and move them next to the AI invocation.
Dedicated spectra.storage.connection + migrate:fresh leaves ghost tables. This is what 2.0.1 fixes, and it is worth understanding rather than just consuming the fix. migrate:fresh only drops tables on the default connection, so when Spectra stores its tables elsewhere they survive the wipe while their migration rows are cleared. The next migrate then hit "relation already exists". 2.0.1 makes both migrations skip tables and columns that already exist, so the run now succeeds — but the trade-off is that a stale Spectra schema is no longer a loud error:
migrate:fresh on an app with a dedicated Spectra connection, the Spectra tables are still the old ones with old data. If you expected a clean slate, drop them explicitly (migrate:fresh --database=<spectra-connection> if that connection is separately configured, or drop manually) before migrating.RefreshDatabase-style tooling in the host app all need to account for the second connection. Pin this in your test bootstrap now rather than debugging "why do my tests see last week's requests".Morph maps need a data check. In 1.x, trackables were stored with the full class name but looked up by morph alias, so budgets and per-user usage found nothing in apps with a morph map. v2 fixes lookups, but existing rows still hold class names. If you use a morph map and had budgets that "didn't work", inspect the stored trackable values and decide whether to backfill or accept a clean break at the upgrade date.
Budget alerts are now once-per-period via the cache. This fixes both the PHP-FPM "fires on every request" and the long-running-worker "never fires again" problems, but it moves the semantics onto your cache:
array driver gives you per-process dedupe, i.e. effectively none.Failures no longer blow up the app — which means they now need watching. Saving a tracked request that fails (e.g. a duplicate daily-stat row under load) is reported rather than thrown. That is correct for production, but: update any tests that assert an exception, and make sure your exception reporter surfaces Spectra's reported failures. Otherwise you get silent gaps in tracking. Related: provider error responses are now stored with the provider's message, and connection failures are recorded and still thrown — so your existing try/catch around provider calls is still required.
Prompt storage off does not mean nothing was stored. In 1.x the raw request body was saved even with prompt storage disabled. Check your prompts-side storage for legacy bodies and scrub or prune them if that matters for privacy or GDPR. Verify the setting actually behaves as you expect on a fresh call before assuming the old data is representative.
Trace data only exists from v2 onward. Older rows have no parent_span_id, conversation_id, or trace lineage, so the Trace card and conversation views will be empty for them. Plan dashboard work and support runbooks around "traces start at the upgrade date".
Treat use_traceparent as an input-validation surface. Joining a client-supplied W3C header means a caller can influence how your traces and spans group. Enable it when you control the upstream (an internal gateway, your own OTel collector); be more cautious on public endpoints, where forged trace ids could let a caller blend their requests into someone else's trace view.
Provider-reported tier now wins over your requested tier. If you deliberately downgrade a request or rely on a project-default tier, pricing follows what the provider says actually served it — which is more correct but means you can't hand-tune cost by setting a tier. Price expectations should come from the provider's report, not your intent.
Pricing lookups: check aliases and since dates when a new model ships. Retired models are kept and marked rather than removed, so historical rows still price. But a model released after your catalog snapshot will not resolve — the symptom is a request row with an unknown model or a $0 cost. The fix is a catalog update, not a workaround.
Clean up 1.x workarounds now that the causes are fixed. Remove: stream-buffering shims, try/catch around Replicate calls guarding the ISO-timestamp subtraction bug (a finished prediction threw into your own Http::post()), any "force a nonzero token count" hacks, and any manual dedupe around async polling.
Smaller things worth knowing:
spectra:prune chunked deletes are slower; schedule with headroom and confirm the configured disk permissions allow media deletion.Spectra::currentTraceId() and Spectra::newTrace() are the two extension points worth adopting immediately — surfacing the trace id in your own logs is the highest-value, lowest-effort integration in this release.composer update and simply re-run migrate, not rebuild their database.How can I help you explore Laravel packages today?