amarenkov/laravel-mutable-content-ai
A Laravel package for working with mutable AI-generated content, letting you store, edit, and refresh model output while keeping it versioned and easy to manage in your application.
This package wraps AI provider interactions in Eloquent-backed models and services, so the fastest way in is to get the schema and config in place, then record a single run and inspect what it produced.
composer require <vendor>/<package>
php artisan vendor:publish --tag="ai-config"
php artisan vendor:publish --tag="ai-migrations"
extend_ai_tables_with_balance migration, then run the seeders — LovsSeeder and FieldsSeeder must be executed after the migration, otherwise the ai_balance_entry_kind LOV and field definitions will be missing:
php artisan migrate
php artisan db:seed --class="Vendor\\Package\\Database\\Seeders\\LovsSeeder"
php artisan db:seed --class="Vendor\\Package\\Database\\Seeders\\FieldsSeeder"
config/ai.php first. As of v0.5.0 this is where prices lives — per-model input/output prices and separate cache read/write prices, expressed in the provider's currency.$run = Runs::record($response); // persists tokens, cache tokens, model, provider
$run->cost(); // float, in the provider's currency
$run->getAttribute(Run::FIELD_CACHE_READ_TOKENS);
$run->getAttribute(Run::FIELD_CACHE_WRITE_TOKENS);
Services\Spending:
app(Spending::class)->total($user); // sums paid runs by currency
app(Spending::class)->daily($user); // per-day breakdown
If cost() returns null or 0.0 on a run you know was billed, the model name on the run almost certainly doesn't match a key in prices — check that before anything else.
Wrap run recording in one place. Runs::record() is the single write path for token and cache-token fields. Put it behind your own action/job so every AI call in the app funnels through it; that guarantees cache read/write tokens are captured and cost() stays computable. Do not hand-write Run rows.
Treat cache tokens as a first-class cost lever. With FIELD_CACHE_READ_TOKENS / FIELD_CACHE_WRITE_TOKENS now persisted, you can quantify prompt caching rather than guess at it. A useful pattern is a small reporting query grouping runs by model and comparing cache-read cost against non-cache input cost from the same prices entry — that's the number that justifies (or kills) a caching strategy.
Spending dashboards, not raw sums. Spending::runs(), total() and daily() already aggregate by currency and by model. Build your admin/user billing views on top of these instead of re-deriving sum(cost) in queries — you get the currency split for free, which matters the moment a user hits two providers.
Reconcile balances instead of trusting the estimate. Balances::remaining() is defined as latest actual balance + top-ups − costs after it. The workflow that keeps this honest:
topUp() whenever you credit the provider account.setActual() with the real figure from the provider dashboard.remaining() do the arithmetic from that anchor point forward.Anything before the most recent setActual() is ignored by design, so a scheduled setActual() (daily cron hitting the provider's balance endpoint, or a manual admin action) is the pattern that keeps the number from drifting.
Use BalanceEntry for an audit trail. entries(), find() and delete() on Services\Balances make it easy to expose a read-only ledger in an admin panel. Prefer delete() over raw model deletes so any package-level invariants hold.
Multi-currency discipline. prices is per-provider-currency. Never sum cost() values across providers into a single scalar — aggregate per currency (which Spending already does) and convert at the presentation layer if you need one number.
Testing. Because prices is config-driven, tests can set a tiny fixture price table and assert exact costs. Stub the recording call with a fixed token payload rather than calling a real provider.
extend_ai_tables_with_balance creates the ai_balance_entries table, but the ai_balance_entry_kind LOV and field definitions arrive via LovsSeeder / FieldsSeeder. Deployments that run migrate but skip seeding will fail when writing balance entries.cost() recalculates from the current prices config. Change a price and every past run's cost changes too. If you need immutable billing figures, snapshot the computed cost onto your own column at record time.prices and yields a zero cost. Log a warning when cost() can't resolve a model.remaining() is an estimate by construction. It's the last actual plus top-ups minus costs after it. If a provider bills you for usage the package never recorded as a Run (other tools, console usage, embedding calls), the estimate will read high. Regular setActual() calls are the antidote — treat it as reconciliation, not a source of truth.daily() and timezones. Day bucketing follows your app timezone; if finance reports on UTC and your app runs on a local zone, daily totals will disagree at month boundaries. Standardise before comparing.Spending::total() returns a per-currency breakdown rather than one figure — render it as multiple amounts or convert deliberately; don't cast it to a single float.prices config is the right place to hang per-tenant or per-plan overrides (resolve them at runtime before calling cost()), rather than forking the Costs service. Similarly, wrap Balances::setActual() in a job if you want automated provider reconciliation, keeping Services\Balances untouched.How can I help you explore Laravel packages today?