I came across the Prompt Migrations concept — treating prompts like DB migrations, versioned and rollable — and it clicked immediately. But the implementation is where I’m stuck. My App\Services are a mess of heredoc strings right now.
The tension I keep running into: if I store prompts in the database, I get instant rollbacks and A/B testing, but my pull requests become meaningless for reviewing prompt changes. Nobody’s going to catch a bad system instruction in a migration file. On the other hand, keeping everything in Git means a full deployment to fix a single hallucination in production. That’s a painful loop when you’re iterating on prompts daily.
I tried a PromptMigration class that seeds the DB on deploy, but it felt like I was just duplicating the problem. Specifically around this pattern from the article:
return Prompt::get('translator')->version('v2')->execute($data);
Two things I can’t settle on:
Should the version string be hardcoded in the Service layer, or should the Service just request the “active” version and let the DB decide? Hardcoding feels brittle; letting the DB decide feels like hidden state.
Local dev is a nightmare with this. If a teammate changes a prompt in their local DB, my environment doesn’t have that version and things break silently. Is there a sane way to sync these — something like php artisan migrate but for prompts?
I want prompts in Git for the PR process, but I need the app to hot-swap versions in production without a merge. Is that achievable cleanly, or am I asking for two things that fundamentally conflict?
What you’re describing is essentially a Goldilocks problem — too much DB reliance and you lose auditability, too much Git reliance and you lose operational flexibility.
The pattern I’ve landed on is what I call the Manifest approach. The key mental shift: stop thinking of the database as the source of the prompt. It’s the deployment target.
Here’s how it works in practice:
resources/prompts/ holds your .md files. That’s the source of truth. Diffs show up in PRs, history is in Git, reviews are meaningful.
A PromptSync command (or migration, if you prefer) reads those files and upserts them into the DB on deploy — similar to how config caching works.
Your Service layer never references a version directly. It calls Prompt::get('translator'), which resolves to whatever row is flagged is_active in the DB.
The hot-fix flow then becomes: you flip is_active to a previous version via a dashboard (Filament, Nova, even Tinker in a pinch). No deployment, no merge. When you get around to a proper fix, the corrected .md file goes through the normal PR process and the next deploy syncs and activates it.
The local dev problem solves itself: PromptSync runs as part of your standard setup, same as migrations. If a teammate adds a new prompt file, it’s in Git. You pull, you run the command, you’re in sync. Nobody’s local DB is a snowflake.
The one thing to be disciplined about: never edit prompts directly in the DB without immediately reflecting the change in the .md file. That’s where the pattern breaks down. Treat the DB toggle as an operational lever, not an authoring environment.
Both problems, one principle: if it’s not automatic, someone on your team will eventually skip it.
For the cache, don’t bother with tagged expiry or manual flushing logic — just cache indefinitely and bust it from a model observer when the record saves:
protected static function booted() {
static::saved(fn ($prompt) => Cache::forget("prompt.{$prompt->name}"));
}
public static function resolve($name) {
return Cache::rememberForever("prompt.{$name}", function() use ($name) {
return static::where('name', $name)->where('is_active', true)->first();
});
}
Two things to be deliberate about here. First, the method is named resolve rather than get — Model::get() is a core Eloquent method and overriding it with a different signature will cause breakage that’s annoying to trace. Second, the observer only fires on Eloquent model events. If your prompts:sync command uses upsert() or raw DB queries internally, it bypasses the observer entirely and the cache won’t bust. Either make sure the sync goes through Eloquent, or call Cache::forget explicitly inside the command itself. Also worth flagging: rememberForever assumes a shared cache driver. File cache across multiple servers means each instance holds its own copy and they’ll drift — make sure you’re on Redis or Memcached before leaning on this.
For the local sync, hook into composer.json using the actual Composer event names:
"scripts": {
"post-install-cmd": [
"@php artisan prompts:sync"
],
"post-update-cmd": [
"@php artisan prompts:sync"
]
}
post-install-cmd fires after composer install, post-update-cmd fires after composer update. Pull a branch, run composer install, your prompts are there. The PromptNotFoundException at 9am stops being your team’s problem.
The mental model that makes this click: Git is where you write prompts, the DB is where they live at runtime, the cache is what actually serves them. Each layer has one job and you’re not conflating any of them.

The
.mdfiles inresources/is exactly what I needed to hear — that alone solves the PR review problem.Two things I’m still unsure about before I commit to this pattern:
On performance: if
Prompt::resolve('translator')is hitting the DB on every call, that’s going to hurt inside a loop or a busy controller. I’m assuming the answer isCache::rememberForeverkeyed to the prompt name, busted when the sync runs — but is that baked into your implementation or is that something I’m wiring up myself? And I’m guessing this only holds if the cache driver is shared — Redis or Memcached — otherwise on multiple servers each instance is caching independently?On local sync: if I pull a branch and my teammate has added a new prompt file to
resources/prompts/, my DB won’t have it and things will just silently break — or not so silently,PromptNotFoundExceptionat 9am is not how I want to start my day. I’d rather not add a command people have to remember to run. Is hookingprompts:syncintopost-install-cmdandpost-update-cmdincomposer.jsonthe right call here, so it fires automatically aftercomposer installandcomposer update?